From bb45f17cf98fd04c23b3a46c4d947beda61f7f0c Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:05:03 +0800 Subject: [PATCH 01/16] feat(daemon): unified agent SDK supporting Claude Code and Codex Add a reusable Go agent package (server/pkg/agent/) that provides a unified Backend interface for executing prompts via either Claude Code or Codex. The daemon now auto-detects which CLIs are available at startup, registers a runtime for each, and routes tasks to the correct backend based on task.Context.Runtime.Provider. Key changes: - server/pkg/agent/agent.go: Backend interface, Message/Result types, factory - server/pkg/agent/claude.go: Spawns claude CLI with stream-json, parses output - server/pkg/agent/codex.go: Spawns codex app-server, JSON-RPC 2.0 protocol - server/cmd/daemon/daemon.go: Multi-runtime registration, round-robin polling, provider-based backend selection. Removes old runCodexExec/codexResultSchema. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/daemon/daemon.go | 345 ++++++++--------- server/cmd/daemon/daemon_test.go | 2 +- server/pkg/agent/agent.go | 99 +++++ server/pkg/agent/claude.go | 324 ++++++++++++++++ server/pkg/agent/codex.go | 632 +++++++++++++++++++++++++++++++ 5 files changed, 1220 insertions(+), 182 deletions(-) create mode 100644 server/pkg/agent/agent.go create mode 100644 server/pkg/agent/claude.go create mode 100644 server/pkg/agent/codex.go diff --git a/server/cmd/daemon/daemon.go b/server/cmd/daemon/daemon.go index 3723d338..e4008efb 100644 --- a/server/cmd/daemon/daemon.go +++ b/server/cmd/daemon/daemon.go @@ -15,6 +15,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/multica-ai/multica/server/pkg/agent" ) const ( @@ -22,11 +24,16 @@ const ( defaultDaemonConfigPath = ".multica/daemon.json" defaultPollInterval = 3 * time.Second defaultHeartbeatInterval = 15 * time.Second - defaultCodexTimeout = 20 * time.Minute - defaultRuntimeName = "Local Codex" - defaultCodexPath = "codex" + defaultAgentTimeout = 20 * time.Minute + defaultRuntimeName = "Local Agent" ) +// agentEntry describes a single available agent CLI. +type agentEntry struct { + Path string // path to CLI binary + Model string // model override (optional) +} + type config struct { ServerBaseURL string ConfigPath string @@ -34,12 +41,11 @@ type config struct { DaemonID string DeviceName string RuntimeName string - CodexPath string - CodexModel string + Agents map[string]agentEntry // "claude" -> entry, "codex" -> entry DefaultWorkdir string PollInterval time.Duration HeartbeatInterval time.Duration - CodexTimeout time.Duration + AgentTimeout time.Duration } type daemon struct { @@ -120,7 +126,7 @@ type daemonRepoRef struct { Path string `json:"path"` } -type codexTaskResult struct { +type taskResult struct { Status string `json:"status"` Comment string `json:"comment"` } @@ -144,9 +150,24 @@ func loadConfig() (config, error) { workspaceID = persisted.WorkspaceID } - codexPath := envOrDefault("MULTICA_CODEX_PATH", defaultCodexPath) - if _, err := exec.LookPath(codexPath); err != nil { - return config{}, fmt.Errorf("codex executable not found at %q: %w", codexPath, 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")), + } + } + if len(agents) == 0 { + return config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH") } host, err := os.Hostname() @@ -154,7 +175,7 @@ func loadConfig() (config, error) { host = "local-machine" } - defaultWorkdir := strings.TrimSpace(os.Getenv("MULTICA_CODEX_WORKDIR")) + defaultWorkdir := strings.TrimSpace(os.Getenv("MULTICA_AGENT_WORKDIR")) if defaultWorkdir == "" { defaultWorkdir, err = os.Getwd() if err != nil { @@ -174,7 +195,7 @@ func loadConfig() (config, error) { if err != nil { return config{}, err } - codexTimeout, err := durationFromEnv("MULTICA_CODEX_TIMEOUT", defaultCodexTimeout) + agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", defaultAgentTimeout) if err != nil { return config{}, err } @@ -185,13 +206,12 @@ func loadConfig() (config, error) { WorkspaceID: workspaceID, DaemonID: envOrDefault("MULTICA_DAEMON_ID", host), DeviceName: envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host), - RuntimeName: envOrDefault("MULTICA_CODEX_RUNTIME_NAME", defaultRuntimeName), - CodexPath: codexPath, - CodexModel: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")), + RuntimeName: envOrDefault("MULTICA_AGENT_RUNTIME_NAME", defaultRuntimeName), + Agents: agents, DefaultWorkdir: defaultWorkdir, PollInterval: pollInterval, HeartbeatInterval: heartbeatInterval, - CodexTimeout: codexTimeout, + AgentTimeout: agentTimeout, }, nil } @@ -204,8 +224,12 @@ func newDaemon(cfg config, logger *log.Logger) *daemon { } func (d *daemon) run(ctx context.Context) error { - d.logger.Printf("starting daemon for workspace=%s server=%s runtime=%s workdir=%s", - d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.RuntimeName, d.cfg.DefaultWorkdir) + agentNames := make([]string, 0, len(d.cfg.Agents)) + for name := range d.cfg.Agents { + agentNames = append(agentNames, name) + } + d.logger.Printf("starting daemon agents=%v workspace=%s server=%s workdir=%s", + agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.DefaultWorkdir) if strings.TrimSpace(d.cfg.WorkspaceID) == "" { workspaceID, err := d.ensurePaired(ctx) @@ -216,50 +240,68 @@ func (d *daemon) run(ctx context.Context) error { d.logger.Printf("pairing completed for workspace=%s", workspaceID) } - runtime, err := d.registerRuntime(ctx) + runtimes, err := d.registerRuntimes(ctx) if err != nil { return err } - d.logger.Printf("registered runtime id=%s provider=%s status=%s", runtime.ID, runtime.Provider, runtime.Status) + runtimeIDs := make([]string, 0, len(runtimes)) + for _, rt := range runtimes { + d.logger.Printf("registered runtime id=%s provider=%s status=%s", rt.ID, rt.Provider, rt.Status) + runtimeIDs = append(runtimeIDs, rt.ID) + } - go d.heartbeatLoop(ctx, runtime.ID) - return d.pollLoop(ctx, runtime.ID) + go d.heartbeatLoop(ctx, runtimeIDs) + return d.pollLoop(ctx, runtimeIDs) } -func (d *daemon) registerRuntime(ctx context.Context) (daemonRuntime, error) { - version, err := detectCodexVersion(ctx, d.cfg.CodexPath) - if err != nil { - return daemonRuntime{}, err +func (d *daemon) registerRuntimes(ctx context.Context) ([]daemonRuntime, error) { + var runtimes []map[string]string + for name, entry := range d.cfg.Agents { + version, err := agent.DetectVersion(ctx, entry.Path) + if err != nil { + d.logger.Printf("skip registering %s: %v", name, err) + continue + } + runtimes = append(runtimes, map[string]string{ + "name": fmt.Sprintf("Local %s", strings.Title(name)), + "type": name, + "version": version, + "status": "online", + }) + } + if len(runtimes) == 0 { + return nil, fmt.Errorf("no agent runtimes could be registered") } req := map[string]any{ "workspace_id": d.cfg.WorkspaceID, "daemon_id": d.cfg.DaemonID, "device_name": d.cfg.DeviceName, - "runtimes": []map[string]string{ - { - "name": d.cfg.RuntimeName, - "type": "codex", - "version": version, - "status": "online", - }, - }, + "runtimes": runtimes, } var resp struct { Runtimes []daemonRuntime `json:"runtimes"` } if err := d.client.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil { - return daemonRuntime{}, fmt.Errorf("register runtime: %w", err) + return nil, fmt.Errorf("register runtimes: %w", err) } if len(resp.Runtimes) == 0 { - return daemonRuntime{}, fmt.Errorf("register runtime: empty response") + return nil, fmt.Errorf("register runtimes: empty response") } - return resp.Runtimes[0], nil + return resp.Runtimes, nil } func (d *daemon) ensurePaired(ctx context.Context) (string, error) { - version, err := detectCodexVersion(ctx, d.cfg.CodexPath) + // Use the first available agent for the pairing session metadata. + var firstName string + var firstEntry agentEntry + for name, entry := range d.cfg.Agents { + firstName = name + firstEntry = entry + break + } + version, err := agent.DetectVersion(ctx, firstEntry.Path) if err != nil { return "", err } @@ -268,14 +310,14 @@ func (d *daemon) ensurePaired(ctx context.Context) (string, error) { "daemon_id": d.cfg.DaemonID, "device_name": d.cfg.DeviceName, "runtime_name": d.cfg.RuntimeName, - "runtime_type": "codex", + "runtime_type": firstName, "runtime_version": version, }) if err != nil { return "", fmt.Errorf("create pairing session: %w", err) } if session.LinkURL != nil { - d.logger.Printf("open this link to pair the local Codex runtime: %s", *session.LinkURL) + d.logger.Printf("open this link to pair the daemon: %s", *session.LinkURL) } else { d.logger.Printf("pairing session created: %s", session.Token) } @@ -318,7 +360,7 @@ func (d *daemon) ensurePaired(ctx context.Context) (string, error) { } } -func (d *daemon) heartbeatLoop(ctx context.Context, runtimeID string) { +func (d *daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { ticker := time.NewTicker(d.cfg.HeartbeatInterval) defer ticker.Stop() @@ -327,17 +369,19 @@ func (d *daemon) heartbeatLoop(ctx context.Context, runtimeID string) { case <-ctx.Done(): return case <-ticker.C: - err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ - "runtime_id": runtimeID, - }, nil) - if err != nil { - d.logger.Printf("heartbeat failed: %v", err) + for _, rid := range runtimeIDs { + err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ + "runtime_id": rid, + }, nil) + if err != nil { + d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err) + } } } } } -func (d *daemon) pollLoop(ctx context.Context, runtimeID string) error { +func (d *daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { for { select { case <-ctx.Done(): @@ -345,34 +389,39 @@ func (d *daemon) pollLoop(ctx context.Context, runtimeID string) error { default: } - task, err := d.client.claimTask(ctx, runtimeID) - if err != nil { - d.logger.Printf("claim task failed: %v", err) - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { - return err + claimed := false + for _, rid := range runtimeIDs { + task, err := d.client.claimTask(ctx, rid) + if err != nil { + d.logger.Printf("claim task failed for runtime %s: %v", rid, err) + continue } - continue - } - if task == nil { - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { - return err + if task != nil { + d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title) + d.handleTask(ctx, *task) + claimed = true + break } - continue } - d.handleTask(ctx, *task) + if !claimed { + if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { + return err + } + } } } func (d *daemon) handleTask(ctx context.Context, task daemonTask) { - d.logger.Printf("picked task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title) + provider := task.Context.Runtime.Provider + d.logger.Printf("picked task=%s issue=%s provider=%s title=%q", task.ID, task.IssueID, provider, task.Context.Issue.Title) if err := d.client.startTask(ctx, task.ID); err != nil { d.logger.Printf("start task %s failed: %v", task.ID, err) return } - _ = d.client.reportProgress(ctx, task.ID, "Launching Codex", 1, 2) + _ = d.client.reportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2) result, err := d.runTask(ctx, task) if err != nil { @@ -397,119 +446,76 @@ func (d *daemon) handleTask(ctx context.Context, task daemonTask) { } } -func (d *daemon) runTask(ctx context.Context, task daemonTask) (codexTaskResult, error) { +func (d *daemon) runTask(ctx context.Context, task daemonTask) (taskResult, error) { + provider := task.Context.Runtime.Provider + entry, ok := d.cfg.Agents[provider] + if !ok { + return taskResult{}, fmt.Errorf("no agent configured for provider %q", provider) + } + workdir, err := resolveTaskWorkdir(d.cfg.DefaultWorkdir, task.Context.Issue.Repository) if err != nil { - return codexTaskResult{}, err + return taskResult{}, err } - prompt := buildCodexPrompt(task, workdir) - runCtx, cancel := context.WithTimeout(ctx, d.cfg.CodexTimeout) - defer cancel() + prompt := buildPrompt(task, workdir) - model := d.cfg.CodexModel - if model == "" { - model = "default" + backend, err := agent.New(provider, agent.Config{ + ExecutablePath: entry.Path, + Logger: d.logger, + }) + if err != nil { + return taskResult{}, fmt.Errorf("create agent backend: %w", err) } - startedAt := time.Now() d.logger.Printf( - "starting codex exec task=%s workdir=%s model=%s timeout=%s", - task.ID, - workdir, - model, - d.cfg.CodexTimeout, + "starting %s task=%s workdir=%s model=%s timeout=%s", + provider, task.ID, workdir, entry.Model, d.cfg.AgentTimeout, ) - result, err := runCodexExec(runCtx, d.cfg, workdir, prompt) + session, err := backend.Execute(ctx, prompt, agent.ExecOptions{ + Cwd: workdir, + Model: entry.Model, + Timeout: d.cfg.AgentTimeout, + }) if err != nil { - d.logger.Printf( - "codex exec failed task=%s duration=%s err=%v", - task.ID, - time.Since(startedAt).Round(time.Millisecond), - err, - ) - if errors.Is(runCtx.Err(), context.DeadlineExceeded) { - return codexTaskResult{}, fmt.Errorf("Codex timed out after %s", d.cfg.CodexTimeout) + return taskResult{}, err + } + + // Drain message channel (log tool uses, ignore text since Result has output) + go func() { + for msg := range session.Messages { + switch msg.Type { + case agent.MessageToolUse: + d.logger.Printf("[%s] tool-use: %s (call=%s)", provider, msg.Tool, msg.CallID) + case agent.MessageError: + d.logger.Printf("[%s] error: %s", provider, msg.Content) + } } - return codexTaskResult{}, err - } + }() - d.logger.Printf( - "codex exec finished task=%s duration=%s status=%s", - task.ID, - time.Since(startedAt).Round(time.Millisecond), - result.Status, - ) - return result, nil + result := <-session.Result + + switch result.Status { + case "completed": + if result.Output == "" { + return taskResult{}, fmt.Errorf("%s returned empty output", provider) + } + return taskResult{Status: "completed", Comment: result.Output}, nil + case "timeout": + return taskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout) + default: + errMsg := result.Error + if errMsg == "" { + errMsg = fmt.Sprintf("%s execution %s", provider, result.Status) + } + return taskResult{Status: "blocked", Comment: errMsg}, nil + } } -func runCodexExec(ctx context.Context, cfg config, workdir, prompt string) (codexTaskResult, error) { - outputFile, err := os.CreateTemp("", "multica-codex-output-*.json") - if err != nil { - return codexTaskResult{}, fmt.Errorf("create codex output file: %w", err) - } - outputPath := outputFile.Name() - outputFile.Close() - defer os.Remove(outputPath) - - schemaFile, err := os.CreateTemp("", "multica-codex-schema-*.json") - if err != nil { - return codexTaskResult{}, fmt.Errorf("create schema file: %w", err) - } - schemaPath := schemaFile.Name() - if _, err := schemaFile.WriteString(codexResultSchema); err != nil { - schemaFile.Close() - return codexTaskResult{}, fmt.Errorf("write schema file: %w", err) - } - schemaFile.Close() - defer os.Remove(schemaPath) - - args := []string{ - "-a", "never", - "exec", - "--skip-git-repo-check", - "--sandbox", "workspace-write", - "-C", workdir, - "--output-schema", schemaPath, - "-o", outputPath, - prompt, - } - if cfg.CodexModel != "" { - args = append([]string{"-m", cfg.CodexModel}, args...) - } - - cmd := exec.CommandContext(ctx, cfg.CodexPath, args...) - var output bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = &output - - if err := cmd.Run(); err != nil { - return codexTaskResult{}, fmt.Errorf("codex exec failed: %w\n%s", err, strings.TrimSpace(output.String())) - } - - data, err := os.ReadFile(outputPath) - if err != nil { - return codexTaskResult{}, fmt.Errorf("read codex result: %w", err) - } - - var result codexTaskResult - if err := json.Unmarshal(data, &result); err != nil { - return codexTaskResult{}, fmt.Errorf("parse codex result: %w", err) - } - if result.Comment == "" { - return codexTaskResult{}, fmt.Errorf("codex returned empty comment") - } - if result.Status == "" { - result.Status = "completed" - } - - return result, nil -} - -func buildCodexPrompt(task daemonTask, workdir string) string { +func buildPrompt(task daemonTask, workdir string) string { var b strings.Builder - b.WriteString("You are running as the local Codex runtime for a Multica agent.\n") + b.WriteString("You are running as a local coding agent for a Multica workspace.\n") b.WriteString("Complete the assigned issue using the local environment.\n") b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n") b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n") @@ -590,15 +596,6 @@ func resolveTaskWorkdir(defaultWorkdir string, repo *daemonRepoRef) (string, err return path, nil } -func detectCodexVersion(ctx context.Context, codexPath string) (string, error) { - cmd := exec.CommandContext(ctx, codexPath, "--version") - data, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("detect codex version: %w", err) - } - return strings.TrimSpace(string(data)), nil -} - func resolveDaemonConfigPath(raw string) (string, error) { if raw != "" { return filepath.Abs(raw) @@ -810,17 +807,3 @@ func (c *daemonClient) getJSON(ctx context.Context, path string, respBody any) e return json.NewDecoder(resp.Body).Decode(respBody) } -const codexResultSchema = `{ - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["completed", "blocked"] - }, - "comment": { - "type": "string" - } - }, - "required": ["status", "comment"], - "additionalProperties": false -}` diff --git a/server/cmd/daemon/daemon_test.go b/server/cmd/daemon/daemon_test.go index a31be2ff..99abc056 100644 --- a/server/cmd/daemon/daemon_test.go +++ b/server/cmd/daemon/daemon_test.go @@ -40,7 +40,7 @@ func TestResolveTaskWorkdirUsesRepoPathWhenPresent(t *testing.T) { func TestBuildCodexPromptIncludesIssueAndSkills(t *testing.T) { t.Parallel() - prompt := buildCodexPrompt(daemonTask{ + prompt := buildPrompt(daemonTask{ Context: daemonTaskContext{ Issue: daemonIssueContext{ Title: "Fix failing test", diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go new file mode 100644 index 00000000..96a73de4 --- /dev/null +++ b/server/pkg/agent/agent.go @@ -0,0 +1,99 @@ +// Package agent provides a unified interface for executing prompts via +// coding agents (Claude Code, Codex). It mirrors the happy-cli AgentBackend +// pattern, translated to idiomatic Go. +package agent + +import ( + "context" + "fmt" + "log" + "time" +) + +// Backend is the unified interface for executing prompts via coding agents. +type Backend interface { + // Execute runs a prompt and returns a Session for streaming results. + // The caller should read from Session.Messages (optional) and wait on + // Session.Result for the final outcome. + Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) +} + +// ExecOptions configures a single execution. +type ExecOptions struct { + Cwd string + Model string + SystemPrompt string + MaxTurns int + Timeout time.Duration +} + +// Session represents a running agent execution. +type Session struct { + // Messages streams events as the agent works. The channel is closed + // when the agent finishes (before Result is sent). + Messages <-chan Message + // Result receives exactly one value — the final outcome — then closes. + Result <-chan Result +} + +// MessageType identifies the kind of Message. +type MessageType string + +const ( + MessageText MessageType = "text" + MessageToolUse MessageType = "tool-use" + MessageToolResult MessageType = "tool-result" + MessageStatus MessageType = "status" + MessageError MessageType = "error" + MessageLog MessageType = "log" +) + +// Message is a unified event emitted by an agent during execution. +type Message struct { + Type MessageType + Content string // text content (Text, Error, Log) + Tool string // tool name (ToolUse, ToolResult) + CallID string // tool call ID (ToolUse, ToolResult) + Input map[string]any // tool input (ToolUse) + Output string // tool output (ToolResult) + Status string // agent status string (Status) + Level string // log level (Log) +} + +// Result is the final outcome after an agent session completes. +type Result struct { + Status string // "completed", "failed", "aborted", "timeout" + Output string // accumulated text output + Error string // error message if failed + DurationMs int64 + SessionID string +} + +// Config configures a Backend instance. +type Config struct { + ExecutablePath string // path to CLI binary (claude or codex) + Env map[string]string // extra environment variables + Logger *log.Logger +} + +// New creates a Backend for the given agent type. +// Supported types: "claude", "codex". +func New(agentType string, cfg Config) (Backend, error) { + if cfg.Logger == nil { + cfg.Logger = log.Default() + } + + switch agentType { + case "claude": + return &claudeBackend{cfg: cfg}, nil + case "codex": + return &codexBackend{cfg: cfg}, nil + default: + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex)", agentType) + } +} + +// DetectVersion runs the agent CLI with --version and returns the output. +func DetectVersion(ctx context.Context, executablePath string) (string, error) { + return detectCLIVersion(ctx, executablePath) +} diff --git a/server/pkg/agent/claude.go b/server/pkg/agent/claude.go new file mode 100644 index 00000000..7749ffbc --- /dev/null +++ b/server/pkg/agent/claude.go @@ -0,0 +1,324 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// claudeBackend implements Backend by spawning the Claude Code CLI +// with --output-format stream-json. +type claudeBackend struct { + cfg Config +} + +func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "claude" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err) + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 20 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + + args := []string{ + "--output-format", "stream-json", + "--verbose", + "--permission-mode", "bypassPermissions", + } + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + if opts.MaxTurns > 0 { + args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns)) + } + if opts.SystemPrompt != "" { + args = append(args, "--append-system-prompt", opts.SystemPrompt) + } + args = append(args, "-p", prompt) + + cmd := exec.CommandContext(runCtx, execPath, args...) + 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("claude stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("claude stdin pipe: %w", err) + } + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start claude: %w", err) + } + + b.cfg.Logger.Printf("[claude] started pid=%d cwd=%s model=%s", cmd.Process.Pid, opts.Cwd, opts.Model) + + msgCh := make(chan Message, 64) + resCh := make(chan Result, 1) + + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + defer stdin.Close() + + startTime := time.Now() + var output strings.Builder + var sessionID string + finalStatus := "completed" + var finalError string + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var msg claudeSDKMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + + switch msg.Type { + case "assistant": + b.handleAssistant(msg, msgCh, &output) + case "user": + b.handleUser(msg, msgCh) + case "system": + if msg.SessionID != "" { + sessionID = msg.SessionID + } + trySend(msgCh, Message{Type: MessageStatus, Status: "running"}) + case "result": + sessionID = msg.SessionID + if msg.ResultText != "" { + output.Reset() + output.WriteString(msg.ResultText) + } + if msg.IsError { + finalStatus = "failed" + finalError = msg.ResultText + } + case "log": + if msg.Log != nil { + trySend(msgCh, Message{ + Type: MessageLog, + Level: msg.Log.Level, + Content: msg.Log.Message, + }) + } + case "control_request": + b.handleControlRequest(msg, stdin) + } + } + + // Wait for process exit + exitErr := cmd.Wait() + duration := time.Since(startTime) + + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("claude timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + finalStatus = "aborted" + finalError = "execution cancelled" + } else if exitErr != nil && finalStatus == "completed" { + finalStatus = "failed" + finalError = fmt.Sprintf("claude exited with error: %v", exitErr) + } + + b.cfg.Logger.Printf("[claude] finished pid=%d status=%s duration=%s", + cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond)) + + resCh <- Result{ + Status: finalStatus, + Output: output.String(), + Error: finalError, + DurationMs: duration.Milliseconds(), + SessionID: sessionID, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, output *strings.Builder) { + var content claudeMessageContent + if err := json.Unmarshal(msg.Message, &content); err != nil { + return + } + + for _, block := range content.Content { + switch block.Type { + case "text": + if block.Text != "" { + output.WriteString(block.Text) + trySend(ch, Message{Type: MessageText, Content: block.Text}) + } + case "tool_use": + var input map[string]any + if block.Input != nil { + _ = json.Unmarshal(block.Input, &input) + } + trySend(ch, Message{ + Type: MessageToolUse, + Tool: block.Name, + CallID: block.ID, + Input: input, + }) + } + } +} + +func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) { + var content claudeMessageContent + if err := json.Unmarshal(msg.Message, &content); err != nil { + return + } + + for _, block := range content.Content { + if block.Type == "tool_result" { + resultStr := "" + if block.Content != nil { + resultStr = string(block.Content) + } + trySend(ch, Message{ + Type: MessageToolResult, + CallID: block.ToolUseID, + Output: resultStr, + }) + } + } +} + +func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) { + // Auto-approve all tool uses in autonomous/daemon mode. + var req claudeControlRequestPayload + if err := json.Unmarshal(msg.Request, &req); err != nil { + return + } + + var inputMap map[string]any + if req.Input != nil { + _ = json.Unmarshal(req.Input, &inputMap) + } + if inputMap == nil { + inputMap = map[string]any{} + } + + response := map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": msg.RequestID, + "response": map[string]any{ + "behavior": "allow", + "updatedInput": inputMap, + }, + }, + } + + data, err := json.Marshal(response) + if err != nil { + return + } + data = append(data, '\n') + _, _ = stdin.Write(data) +} + +// ── Claude SDK JSON types ── + +type claudeSDKMessage struct { + Type string `json:"type"` + Message json.RawMessage `json:"message,omitempty"` + Subtype string `json:"subtype,omitempty"` + SessionID string `json:"session_id,omitempty"` + + // result fields + ResultText string `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + DurationMs float64 `json:"duration_ms,omitempty"` + NumTurns int `json:"num_turns,omitempty"` + + // log fields + Log *claudeLogEntry `json:"log,omitempty"` + + // control request fields + RequestID string `json:"request_id,omitempty"` + Request json.RawMessage `json:"request,omitempty"` +} + +type claudeLogEntry struct { + Level string `json:"level"` + Message string `json:"message"` +} + +type claudeMessageContent struct { + Role string `json:"role"` + Content []claudeContentBlock `json:"content"` +} + +type claudeContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content json.RawMessage `json:"content,omitempty"` +} + +type claudeControlRequestPayload struct { + Subtype string `json:"subtype"` + ToolName string `json:"tool_name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// ── Shared helpers ── + +func trySend(ch chan<- Message, msg Message) { + select { + case ch <- msg: + default: + } +} + +func buildEnv(extra map[string]string) []string { + env := os.Environ() + for k, v := range extra { + env = append(env, k+"="+v) + } + return env +} + +func detectCLIVersion(ctx context.Context, execPath string) (string, error) { + cmd := exec.CommandContext(ctx, execPath, "--version") + data, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("detect version for %s: %w", execPath, err) + } + return strings.TrimSpace(string(data)), nil +} diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go new file mode 100644 index 00000000..95f68fbc --- /dev/null +++ b/server/pkg/agent/codex.go @@ -0,0 +1,632 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// codexBackend implements Backend by spawning `codex app-server --listen stdio://` +// and communicating via JSON-RPC 2.0 over stdin/stdout. +type codexBackend struct { + cfg Config +} + +func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "codex" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("codex executable not found at %q: %w", execPath, err) + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 20 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + + cmd := exec.CommandContext(runCtx, execPath, "app-server", "--listen", "stdio://") + 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("codex stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("codex stdin pipe: %w", err) + } + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start codex: %w", err) + } + + b.cfg.Logger.Printf("[codex] started app-server pid=%d cwd=%s", cmd.Process.Pid, opts.Cwd) + + c := &codexClient{ + cfg: b.cfg, + stdin: stdin, + pending: make(map[int]*pendingRPC), + } + + msgCh := make(chan Message, 64) + resCh := make(chan Result, 1) + + // Start reading stdout in background + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + c.handleLine(line, msgCh) + } + c.closeAllPending(fmt.Errorf("codex process exited")) + }() + + // Drive the session lifecycle in a goroutine + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + defer func() { + stdin.Close() + _ = cmd.Wait() + }() + + startTime := time.Now() + finalStatus := "completed" + var finalError string + var output strings.Builder + + // Drain messages to accumulate output + c.onMessage = func(msg Message) { + if msg.Type == MessageText { + output.WriteString(msg.Content) + } + trySend(msgCh, msg) + } + + // 1. Initialize handshake + _, err := c.request(runCtx, "initialize", map[string]any{ + "clientInfo": map[string]any{ + "name": "multica-agent-sdk", + "title": "Multica Agent SDK", + "version": "0.2.0", + }, + "capabilities": map[string]any{ + "experimentalApi": true, + }, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("codex initialize failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + c.notify("initialized") + + // 2. Start thread + threadResult, err := c.request(runCtx, "thread/start", map[string]any{ + "model": nilIfEmpty(opts.Model), + "modelProvider": nil, + "profile": nil, + "cwd": opts.Cwd, + "approvalPolicy": nil, + "sandbox": "workspace-write", + "config": nil, + "baseInstructions": nil, + "developerInstructions": nilIfEmpty(opts.SystemPrompt), + "compactPrompt": nil, + "includeApplyPatchTool": nil, + "experimentalRawEvents": false, + "persistExtendedHistory": true, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("codex thread/start failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + + threadID := extractThreadID(threadResult) + if threadID == "" { + finalStatus = "failed" + finalError = "codex thread/start returned no thread ID" + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + c.threadID = threadID + b.cfg.Logger.Printf("[codex] thread started: %s", threadID) + + // 3. Send turn and wait for completion + turnDone := make(chan bool, 1) // true = aborted + c.onTurnDone = func(aborted bool) { + select { + case turnDone <- aborted: + default: + } + } + + _, err = c.request(runCtx, "turn/start", map[string]any{ + "threadId": threadID, + "input": []map[string]any{ + {"type": "text", "text": prompt}, + }, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("codex turn/start failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + + // Wait for turn completion or context cancellation + select { + case aborted := <-turnDone: + if aborted { + finalStatus = "aborted" + finalError = "turn was aborted" + } + case <-runCtx.Done(): + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("codex timed out after %s", timeout) + } else { + finalStatus = "aborted" + finalError = "execution cancelled" + } + } + + duration := time.Since(startTime) + b.cfg.Logger.Printf("[codex] finished pid=%d status=%s duration=%s", + cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond)) + + resCh <- Result{ + Status: finalStatus, + Output: output.String(), + Error: finalError, + DurationMs: duration.Milliseconds(), + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +// ── codexClient: JSON-RPC 2.0 transport ── + +type codexClient struct { + cfg Config + stdin interface{ Write([]byte) (int, error) } + mu sync.Mutex + nextID int + pending map[int]*pendingRPC + threadID string + turnID string + onMessage func(Message) + onTurnDone func(aborted bool) + + notificationProtocol string // "unknown", "legacy", "raw" + turnStarted bool + completedTurnIDs map[string]bool +} + +type pendingRPC struct { + ch chan rpcResult + method string +} + +type rpcResult struct { + result json.RawMessage + err error +} + +func (c *codexClient) request(ctx context.Context, method string, params any) (json.RawMessage, error) { + c.mu.Lock() + c.nextID++ + id := c.nextID + pr := &pendingRPC{ch: make(chan rpcResult, 1), method: method} + c.pending[id] = pr + c.mu.Unlock() + + msg := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + data, err := json.Marshal(msg) + if err != nil { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + return nil, err + } + data = append(data, '\n') + if _, err := c.stdin.Write(data); err != nil { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + return nil, fmt.Errorf("write %s: %w", method, err) + } + + select { + case res := <-pr.ch: + return res.result, res.err + case <-ctx.Done(): + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + return nil, ctx.Err() + } +} + +func (c *codexClient) notify(method string) { + msg := map[string]any{ + "jsonrpc": "2.0", + "method": method, + } + data, _ := json.Marshal(msg) + data = append(data, '\n') + _, _ = c.stdin.Write(data) +} + +func (c *codexClient) respond(id int, result any) { + msg := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": result, + } + data, _ := json.Marshal(msg) + data = append(data, '\n') + _, _ = c.stdin.Write(data) +} + +func (c *codexClient) closeAllPending(err error) { + c.mu.Lock() + defer c.mu.Unlock() + for id, pr := range c.pending { + pr.ch <- rpcResult{err: err} + delete(c.pending, id) + } +} + +func (c *codexClient) handleLine(line string, msgCh chan<- Message) { + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(line), &raw); err != nil { + return + } + + // Check if it's a response to our request + if _, hasID := raw["id"]; hasID { + if _, hasResult := raw["result"]; hasResult { + c.handleResponse(raw) + return + } + if _, hasError := raw["error"]; hasError { + c.handleResponse(raw) + return + } + // Server request (has id + method) + if _, hasMethod := raw["method"]; hasMethod { + c.handleServerRequest(raw) + return + } + } + + // Notification (no id, has method) + if _, hasMethod := raw["method"]; hasMethod { + c.handleNotification(raw) + } +} + +func (c *codexClient) handleResponse(raw map[string]json.RawMessage) { + var id int + if err := json.Unmarshal(raw["id"], &id); err != nil { + return + } + + c.mu.Lock() + pr, ok := c.pending[id] + if ok { + delete(c.pending, id) + } + c.mu.Unlock() + + if !ok { + return + } + + if errData, hasErr := raw["error"]; hasErr { + var rpcErr struct { + Code int `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(errData, &rpcErr) + pr.ch <- rpcResult{err: fmt.Errorf("%s: %s (code=%d)", pr.method, rpcErr.Message, rpcErr.Code)} + } else { + pr.ch <- rpcResult{result: raw["result"]} + } +} + +func (c *codexClient) handleServerRequest(raw map[string]json.RawMessage) { + var id int + _ = json.Unmarshal(raw["id"], &id) + + var method string + _ = json.Unmarshal(raw["method"], &method) + + // Auto-approve all exec/patch requests in daemon mode + switch method { + case "item/commandExecution/requestApproval", "execCommandApproval": + c.respond(id, map[string]any{"decision": "accept"}) + case "item/fileChange/requestApproval", "applyPatchApproval": + c.respond(id, map[string]any{"decision": "accept"}) + default: + c.respond(id, map[string]any{}) + } +} + +func (c *codexClient) handleNotification(raw map[string]json.RawMessage) { + var method string + _ = json.Unmarshal(raw["method"], &method) + + var params map[string]any + if p, ok := raw["params"]; ok { + _ = json.Unmarshal(p, ¶ms) + } + + // Legacy codex/event notifications + if method == "codex/event" || strings.HasPrefix(method, "codex/event/") { + c.notificationProtocol = "legacy" + msgData, ok := params["msg"] + if !ok { + return + } + msgMap, ok := msgData.(map[string]any) + if !ok { + return + } + c.handleEvent(msgMap) + return + } + + // Raw v2 notifications + if c.notificationProtocol != "legacy" { + if c.notificationProtocol == "unknown" && + (method == "turn/started" || method == "turn/completed" || + method == "thread/started" || strings.HasPrefix(method, "item/")) { + c.notificationProtocol = "raw" + } + + if c.notificationProtocol == "raw" { + c.handleRawNotification(method, params) + } + } +} + +func (c *codexClient) handleEvent(msg map[string]any) { + msgType, _ := msg["type"].(string) + + switch msgType { + case "task_started": + c.turnStarted = true + if c.onMessage != nil { + c.onMessage(Message{Type: MessageStatus, Status: "running"}) + } + case "agent_message": + text, _ := msg["message"].(string) + if text != "" && c.onMessage != nil { + c.onMessage(Message{Type: MessageText, Content: text}) + } + case "exec_command_begin": + callID, _ := msg["call_id"].(string) + command, _ := msg["command"].(string) + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolUse, + Tool: "exec_command", + CallID: callID, + Input: map[string]any{"command": command}, + }) + } + case "exec_command_end": + callID, _ := msg["call_id"].(string) + output, _ := msg["output"].(string) + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolResult, + Tool: "exec_command", + CallID: callID, + Output: output, + }) + } + case "patch_apply_begin": + callID, _ := msg["call_id"].(string) + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolUse, + Tool: "patch_apply", + CallID: callID, + }) + } + case "patch_apply_end": + callID, _ := msg["call_id"].(string) + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolResult, + Tool: "patch_apply", + CallID: callID, + }) + } + case "task_complete": + if c.onTurnDone != nil { + c.onTurnDone(false) + } + case "turn_aborted": + if c.onTurnDone != nil { + c.onTurnDone(true) + } + } +} + +func (c *codexClient) handleRawNotification(method string, params map[string]any) { + switch method { + case "turn/started": + c.turnStarted = true + if turnID := extractNestedString(params, "turn", "id"); turnID != "" { + c.turnID = turnID + } + if c.onMessage != nil { + c.onMessage(Message{Type: MessageStatus, Status: "running"}) + } + + case "turn/completed": + turnID := extractNestedString(params, "turn", "id") + status := extractNestedString(params, "turn", "status") + aborted := status == "cancelled" || status == "canceled" || + status == "aborted" || status == "interrupted" + + if c.completedTurnIDs == nil { + c.completedTurnIDs = map[string]bool{} + } + if turnID != "" { + if c.completedTurnIDs[turnID] { + return + } + c.completedTurnIDs[turnID] = true + } + + if c.onTurnDone != nil { + c.onTurnDone(aborted) + } + + case "thread/status/changed": + statusType := extractNestedString(params, "status", "type") + if statusType == "idle" && c.turnStarted { + if c.onTurnDone != nil { + c.onTurnDone(false) + } + } + + default: + if strings.HasPrefix(method, "item/") { + c.handleItemNotification(method, params) + } + } +} + +func (c *codexClient) handleItemNotification(method string, params map[string]any) { + item, ok := params["item"].(map[string]any) + if !ok { + return + } + + itemType, _ := item["type"].(string) + itemID, _ := item["id"].(string) + + switch { + case method == "item/started" && itemType == "commandExecution": + command, _ := item["command"].(string) + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolUse, + Tool: "exec_command", + CallID: itemID, + Input: map[string]any{"command": command}, + }) + } + + case method == "item/completed" && itemType == "commandExecution": + output, _ := item["aggregatedOutput"].(string) + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolResult, + Tool: "exec_command", + CallID: itemID, + Output: output, + }) + } + + case method == "item/started" && itemType == "fileChange": + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolUse, + Tool: "patch_apply", + CallID: itemID, + }) + } + + case method == "item/completed" && itemType == "fileChange": + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolResult, + Tool: "patch_apply", + CallID: itemID, + }) + } + + case method == "item/completed" && itemType == "agentMessage": + text, _ := item["text"].(string) + if text != "" && c.onMessage != nil { + c.onMessage(Message{Type: MessageText, Content: text}) + } + phase, _ := item["phase"].(string) + if phase == "final_answer" && c.turnStarted { + if c.onTurnDone != nil { + c.onTurnDone(false) + } + } + } +} + +// ── Helpers ── + +func extractThreadID(result json.RawMessage) string { + var r struct { + Thread struct { + ID string `json:"id"` + } `json:"thread"` + } + if err := json.Unmarshal(result, &r); err != nil { + return "" + } + return r.Thread.ID +} + +func extractNestedString(m map[string]any, keys ...string) string { + current := any(m) + for _, key := range keys { + obj, ok := current.(map[string]any) + if !ok { + return "" + } + current = obj[key] + } + s, _ := current.(string) + return s +} + +func nilIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} From 0d9b687d928fb1c558c992b4be7184eed5b04da7 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:10:08 +0800 Subject: [PATCH 02/16] fix(agent): address code review feedback - Replace deprecated strings.Title with manual capitalize - Fix race: set codexClient.onMessage before starting reader goroutine - Remove unused msgCh parameter from codexClient.handleLine - Route agent stderr through logger instead of dumping to os.Stderr - Use deterministic agent order in ensurePaired (prefer codex) - Increase message channel buffer from 64 to 256 - Rename test to match function rename (buildPrompt) Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/daemon/daemon.go | 14 ++++++++------ server/cmd/daemon/daemon_test.go | 2 +- server/pkg/agent/claude.go | 25 +++++++++++++++++++++++-- server/pkg/agent/codex.go | 31 +++++++++++++++---------------- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/server/cmd/daemon/daemon.go b/server/cmd/daemon/daemon.go index e4008efb..ddc6e0a5 100644 --- a/server/cmd/daemon/daemon.go +++ b/server/cmd/daemon/daemon.go @@ -263,7 +263,7 @@ func (d *daemon) registerRuntimes(ctx context.Context) ([]daemonRuntime, error) continue } runtimes = append(runtimes, map[string]string{ - "name": fmt.Sprintf("Local %s", strings.Title(name)), + "name": fmt.Sprintf("Local %s", strings.ToUpper(name[:1])+name[1:]), "type": name, "version": version, "status": "online", @@ -293,13 +293,15 @@ func (d *daemon) registerRuntimes(ctx context.Context) ([]daemonRuntime, error) } func (d *daemon) ensurePaired(ctx context.Context) (string, error) { - // Use the first available agent for the pairing session metadata. + // Use a deterministic agent for the pairing session metadata (prefer codex for backward compat). var firstName string var firstEntry agentEntry - for name, entry := range d.cfg.Agents { - firstName = name - firstEntry = entry - break + for _, preferred := range []string{"codex", "claude"} { + if entry, ok := d.cfg.Agents[preferred]; ok { + firstName = preferred + firstEntry = entry + break + } } version, err := agent.DetectVersion(ctx, firstEntry.Path) if err != nil { diff --git a/server/cmd/daemon/daemon_test.go b/server/cmd/daemon/daemon_test.go index 99abc056..42efebc4 100644 --- a/server/cmd/daemon/daemon_test.go +++ b/server/cmd/daemon/daemon_test.go @@ -37,7 +37,7 @@ func TestResolveTaskWorkdirUsesRepoPathWhenPresent(t *testing.T) { } } -func TestBuildCodexPromptIncludesIssueAndSkills(t *testing.T) { +func TestBuildPromptIncludesIssueAndSkills(t *testing.T) { t.Parallel() prompt := buildPrompt(daemonTask{ diff --git a/server/pkg/agent/claude.go b/server/pkg/agent/claude.go index 7749ffbc..cb735936 100644 --- a/server/pkg/agent/claude.go +++ b/server/pkg/agent/claude.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log" "os" "os/exec" "strings" @@ -64,7 +65,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt cancel() return nil, fmt.Errorf("claude stdin pipe: %w", err) } - cmd.Stderr = os.Stderr + cmd.Stderr = newLogWriter(b.cfg.Logger, "[claude:stderr] ") if err := cmd.Start(); err != nil { cancel() @@ -73,7 +74,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt b.cfg.Logger.Printf("[claude] started pid=%d cwd=%s model=%s", cmd.Process.Pid, opts.Cwd, opts.Model) - msgCh := make(chan Message, 64) + msgCh := make(chan Message, 256) resCh := make(chan Result, 1) go func() { @@ -303,6 +304,8 @@ func trySend(ch chan<- Message, msg Message) { select { case ch <- msg: default: + // Channel full — drop message. Final output is accumulated separately + // in Result.Output, so only streaming consumers are affected. } } @@ -322,3 +325,21 @@ func detectCLIVersion(ctx context.Context, execPath string) (string, error) { } return strings.TrimSpace(string(data)), nil } + +// logWriter adapts a *log.Logger to an io.Writer for capturing stderr. +type logWriter struct { + logger *log.Logger + prefix string +} + +func newLogWriter(logger *log.Logger, prefix string) *logWriter { + return &logWriter{logger: logger, prefix: prefix} +} + +func (w *logWriter) Write(p []byte) (int, error) { + text := strings.TrimSpace(string(p)) + if text != "" { + w.logger.Printf("%s%s", w.prefix, text) + } + return len(p), nil +} diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 95f68fbc..6028cc69 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "os" "os/exec" "strings" "sync" @@ -49,7 +48,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti cancel() return nil, fmt.Errorf("codex stdin pipe: %w", err) } - cmd.Stderr = os.Stderr + cmd.Stderr = newLogWriter(b.cfg.Logger, "[codex:stderr] ") if err := cmd.Start(); err != nil { cancel() @@ -58,15 +57,24 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti b.cfg.Logger.Printf("[codex] started app-server pid=%d cwd=%s", cmd.Process.Pid, opts.Cwd) + msgCh := make(chan Message, 256) + resCh := make(chan Result, 1) + + var output strings.Builder + c := &codexClient{ cfg: b.cfg, stdin: stdin, pending: make(map[int]*pendingRPC), + // Set onMessage before starting the reader goroutine to avoid a race. + onMessage: func(msg Message) { + if msg.Type == MessageText { + output.WriteString(msg.Content) + } + trySend(msgCh, msg) + }, } - msgCh := make(chan Message, 64) - resCh := make(chan Result, 1) - // Start reading stdout in background go func() { scanner := bufio.NewScanner(stdout) @@ -76,7 +84,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti if line == "" { continue } - c.handleLine(line, msgCh) + c.handleLine(line) } c.closeAllPending(fmt.Errorf("codex process exited")) }() @@ -94,15 +102,6 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti startTime := time.Now() finalStatus := "completed" var finalError string - var output strings.Builder - - // Drain messages to accumulate output - c.onMessage = func(msg Message) { - if msg.Type == MessageText { - output.WriteString(msg.Content) - } - trySend(msgCh, msg) - } // 1. Initialize handshake _, err := c.request(runCtx, "initialize", map[string]any{ @@ -308,7 +307,7 @@ func (c *codexClient) closeAllPending(err error) { } } -func (c *codexClient) handleLine(line string, msgCh chan<- Message) { +func (c *codexClient) handleLine(line string) { var raw map[string]json.RawMessage if err := json.Unmarshal([]byte(line), &raw); err != nil { return From 96cfdc2e27884bde58a8ea5a236a3445fdbbf608 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:21:10 +0800 Subject: [PATCH 03/16] fix(agent): fix data races, add tests, and fix raw protocol detection - Fix data race on output strings.Builder in codex backend by adding mutex and waiting for reader goroutine before reading final output - Fix data race on onTurnDone by initializing it before reader starts - Fix bug where notificationProtocol zero value "" never matched "unknown", silently dropping all raw v2 notifications from codex - Add round-robin polling to prevent runtime starvation in poll loop - Log errors in claude handleControlRequest instead of silently dropping - Add 35 tests for pkg/agent covering claude parsing, codex JSON-RPC, protocol detection, event handling, and helper functions Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/daemon/daemon.go | 7 +- server/pkg/agent/agent_test.go | 53 ++++ server/pkg/agent/claude.go | 5 +- server/pkg/agent/claude_test.go | 229 ++++++++++++++ server/pkg/agent/codex.go | 40 ++- server/pkg/agent/codex_test.go | 545 ++++++++++++++++++++++++++++++++ 6 files changed, 864 insertions(+), 15 deletions(-) create mode 100644 server/pkg/agent/agent_test.go create mode 100644 server/pkg/agent/claude_test.go create mode 100644 server/pkg/agent/codex_test.go diff --git a/server/cmd/daemon/daemon.go b/server/cmd/daemon/daemon.go index ddc6e0a5..71f1c864 100644 --- a/server/cmd/daemon/daemon.go +++ b/server/cmd/daemon/daemon.go @@ -384,6 +384,7 @@ func (d *daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { } func (d *daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { + pollOffset := 0 for { select { case <-ctx.Done(): @@ -392,7 +393,9 @@ func (d *daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { } claimed := false - for _, rid := range runtimeIDs { + n := len(runtimeIDs) + for i := 0; i < n; i++ { + rid := runtimeIDs[(pollOffset+i)%n] task, err := d.client.claimTask(ctx, rid) if err != nil { d.logger.Printf("claim task failed for runtime %s: %v", rid, err) @@ -402,11 +405,13 @@ func (d *daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title) d.handleTask(ctx, *task) claimed = true + pollOffset = (pollOffset + i + 1) % n break } } if !claimed { + pollOffset = (pollOffset + 1) % n if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { return err } diff --git a/server/pkg/agent/agent_test.go b/server/pkg/agent/agent_test.go new file mode 100644 index 00000000..7ddec759 --- /dev/null +++ b/server/pkg/agent/agent_test.go @@ -0,0 +1,53 @@ +package agent + +import ( + "context" + "testing" +) + +func TestNewReturnsClaudeBackend(t *testing.T) { + t.Parallel() + b, err := New("claude", Config{ExecutablePath: "/nonexistent/claude"}) + if err != nil { + t.Fatalf("New(claude) error: %v", err) + } + if _, ok := b.(*claudeBackend); !ok { + t.Fatalf("expected *claudeBackend, got %T", b) + } +} + +func TestNewReturnsCodexBackend(t *testing.T) { + t.Parallel() + b, err := New("codex", Config{ExecutablePath: "/nonexistent/codex"}) + if err != nil { + t.Fatalf("New(codex) error: %v", err) + } + if _, ok := b.(*codexBackend); !ok { + t.Fatalf("expected *codexBackend, got %T", b) + } +} + +func TestNewRejectsUnknownType(t *testing.T) { + t.Parallel() + _, err := New("gpt", Config{}) + if err == nil { + t.Fatal("expected error for unknown agent type") + } +} + +func TestNewDefaultsLogger(t *testing.T) { + t.Parallel() + b, _ := New("claude", Config{}) + cb := b.(*claudeBackend) + if cb.cfg.Logger == nil { + t.Fatal("expected non-nil logger") + } +} + +func TestDetectVersionFailsForMissingBinary(t *testing.T) { + t.Parallel() + _, err := DetectVersion(context.Background(), "/nonexistent/binary") + if err == nil { + t.Fatal("expected error for missing binary") + } +} diff --git a/server/pkg/agent/claude.go b/server/pkg/agent/claude.go index cb735936..1093b739 100644 --- a/server/pkg/agent/claude.go +++ b/server/pkg/agent/claude.go @@ -244,10 +244,13 @@ func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interfa data, err := json.Marshal(response) if err != nil { + b.cfg.Logger.Printf("[claude] failed to marshal control response: %v", err) return } data = append(data, '\n') - _, _ = stdin.Write(data) + if _, err := stdin.Write(data); err != nil { + b.cfg.Logger.Printf("[claude] failed to write control response: %v", err) + } } // ── Claude SDK JSON types ── diff --git a/server/pkg/agent/claude_test.go b/server/pkg/agent/claude_test.go new file mode 100644 index 00000000..8018a2bf --- /dev/null +++ b/server/pkg/agent/claude_test.go @@ -0,0 +1,229 @@ +package agent + +import ( + "bytes" + "encoding/json" + "log" + "strings" + "testing" +) + +func TestClaudeHandleAssistantText(t *testing.T) { + t.Parallel() + + b := &claudeBackend{cfg: Config{Logger: log.Default()}} + ch := make(chan Message, 10) + var output strings.Builder + + msg := claudeSDKMessage{ + Type: "assistant", + Message: mustMarshal(t, claudeMessageContent{ + Role: "assistant", + Content: []claudeContentBlock{ + {Type: "text", Text: "Hello world"}, + }, + }), + } + + b.handleAssistant(msg, ch, &output) + + if output.String() != "Hello world" { + t.Fatalf("expected output 'Hello world', got %q", output.String()) + } + select { + case m := <-ch: + if m.Type != MessageText || m.Content != "Hello world" { + t.Fatalf("unexpected message: %+v", m) + } + default: + t.Fatal("expected message on channel") + } +} + +func TestClaudeHandleAssistantToolUse(t *testing.T) { + t.Parallel() + + b := &claudeBackend{cfg: Config{Logger: log.Default()}} + ch := make(chan Message, 10) + var output strings.Builder + + msg := claudeSDKMessage{ + Type: "assistant", + Message: mustMarshal(t, claudeMessageContent{ + Role: "assistant", + Content: []claudeContentBlock{ + { + Type: "tool_use", + ID: "call-1", + Name: "Read", + Input: mustMarshal(t, map[string]any{"path": "/tmp/foo"}), + }, + }, + }), + } + + b.handleAssistant(msg, ch, &output) + + if output.String() != "" { + t.Fatalf("tool_use should not add to output, got %q", output.String()) + } + select { + case m := <-ch: + if m.Type != MessageToolUse || m.Tool != "Read" || m.CallID != "call-1" { + t.Fatalf("unexpected message: %+v", m) + } + if m.Input["path"] != "/tmp/foo" { + t.Fatalf("expected input path /tmp/foo, got %v", m.Input["path"]) + } + default: + t.Fatal("expected message on channel") + } +} + +func TestClaudeHandleUserToolResult(t *testing.T) { + t.Parallel() + + b := &claudeBackend{cfg: Config{Logger: log.Default()}} + ch := make(chan Message, 10) + + msg := claudeSDKMessage{ + Type: "user", + Message: mustMarshal(t, claudeMessageContent{ + Role: "user", + Content: []claudeContentBlock{ + { + Type: "tool_result", + ToolUseID: "call-1", + Content: mustMarshal(t, "file contents here"), + }, + }, + }), + } + + b.handleUser(msg, ch) + + select { + case m := <-ch: + if m.Type != MessageToolResult || m.CallID != "call-1" { + t.Fatalf("unexpected message: %+v", m) + } + default: + t.Fatal("expected message on channel") + } +} + +func TestClaudeHandleControlRequestAutoApproves(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + b := &claudeBackend{cfg: Config{Logger: log.New(&buf, "", 0)}} + + var written bytes.Buffer + + msg := claudeSDKMessage{ + Type: "control_request", + RequestID: "req-42", + Request: mustMarshal(t, claudeControlRequestPayload{ + Subtype: "tool_use", + ToolName: "Bash", + Input: mustMarshal(t, map[string]any{"command": "ls"}), + }), + } + + b.handleControlRequest(msg, &written) + + var resp map[string]any + if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if resp["type"] != "control_response" { + t.Fatalf("expected type control_response, got %v", resp["type"]) + } + respInner := resp["response"].(map[string]any) + if respInner["request_id"] != "req-42" { + t.Fatalf("expected request_id req-42, got %v", respInner["request_id"]) + } + innerResp := respInner["response"].(map[string]any) + if innerResp["behavior"] != "allow" { + t.Fatalf("expected behavior allow, got %v", innerResp["behavior"]) + } +} + +func TestClaudeHandleAssistantInvalidJSON(t *testing.T) { + t.Parallel() + + b := &claudeBackend{cfg: Config{Logger: log.Default()}} + ch := make(chan Message, 10) + var output strings.Builder + + msg := claudeSDKMessage{ + Type: "assistant", + Message: json.RawMessage(`invalid json`), + } + + // Should not panic + b.handleAssistant(msg, ch, &output) + + if output.String() != "" { + t.Fatalf("expected empty output for invalid JSON, got %q", output.String()) + } + select { + case m := <-ch: + t.Fatalf("expected no message, got %+v", m) + default: + } +} + +func TestTrySendDropsWhenFull(t *testing.T) { + t.Parallel() + + ch := make(chan Message, 1) + // Fill the channel + trySend(ch, Message{Type: MessageText, Content: "first"}) + // This should not block + trySend(ch, Message{Type: MessageText, Content: "second"}) + + m := <-ch + if m.Content != "first" { + t.Fatalf("expected 'first', got %q", m.Content) + } + select { + case m := <-ch: + t.Fatalf("expected empty channel, got %+v", m) + default: + } +} + +func TestBuildEnvAppendsExtras(t *testing.T) { + t.Parallel() + + env := buildEnv(map[string]string{"FOO": "bar", "BAZ": "qux"}) + found := 0 + for _, e := range env { + if e == "FOO=bar" || e == "BAZ=qux" { + found++ + } + } + if found != 2 { + t.Fatalf("expected 2 extra env vars, found %d", found) + } +} + +func TestBuildEnvNilExtras(t *testing.T) { + t.Parallel() + + env := buildEnv(nil) + if len(env) == 0 { + t.Fatal("expected at least system env vars") + } +} + +func mustMarshal(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + return data +} diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 6028cc69..97a67a89 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -60,23 +60,38 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti msgCh := make(chan Message, 256) resCh := make(chan Result, 1) + var outputMu sync.Mutex var output strings.Builder + // turnDone is set before starting the reader goroutine so there is no + // race between the lifecycle goroutine writing and the reader reading. + turnDone := make(chan bool, 1) // true = aborted + c := &codexClient{ - cfg: b.cfg, - stdin: stdin, - pending: make(map[int]*pendingRPC), - // Set onMessage before starting the reader goroutine to avoid a race. + cfg: b.cfg, + stdin: stdin, + pending: make(map[int]*pendingRPC), + notificationProtocol: "unknown", onMessage: func(msg Message) { if msg.Type == MessageText { + outputMu.Lock() output.WriteString(msg.Content) + outputMu.Unlock() } trySend(msgCh, msg) }, + onTurnDone: func(aborted bool) { + select { + case turnDone <- aborted: + default: + } + }, } // Start reading stdout in background + readerDone := make(chan struct{}) go func() { + defer close(readerDone) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) for scanner.Scan() { @@ -156,14 +171,6 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti b.cfg.Logger.Printf("[codex] thread started: %s", threadID) // 3. Send turn and wait for completion - turnDone := make(chan bool, 1) // true = aborted - c.onTurnDone = func(aborted bool) { - select { - case turnDone <- aborted: - default: - } - } - _, err = c.request(runCtx, "turn/start", map[string]any{ "threadId": threadID, "input": []map[string]any{ @@ -198,9 +205,16 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti b.cfg.Logger.Printf("[codex] finished pid=%d status=%s duration=%s", cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond)) + // Wait for the reader goroutine to finish so all output is accumulated. + <-readerDone + + outputMu.Lock() + finalOutput := output.String() + outputMu.Unlock() + resCh <- Result{ Status: finalStatus, - Output: output.String(), + Output: finalOutput, Error: finalError, DurationMs: duration.Milliseconds(), } diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go new file mode 100644 index 00000000..dc3f64b1 --- /dev/null +++ b/server/pkg/agent/codex_test.go @@ -0,0 +1,545 @@ +package agent + +import ( + "encoding/json" + "fmt" + "log" + "sync" + "testing" +) + +func newTestCodexClient(t *testing.T) (*codexClient, *fakeStdin, []Message) { + t.Helper() + fs := &fakeStdin{} + var mu sync.Mutex + var messages []Message + + c := &codexClient{ + cfg: Config{Logger: log.Default()}, + stdin: fs, + pending: make(map[int]*pendingRPC), + onMessage: func(msg Message) { + mu.Lock() + messages = append(messages, msg) + mu.Unlock() + }, + onTurnDone: func(aborted bool) {}, + } + return c, fs, messages +} + +type fakeStdin struct { + mu sync.Mutex + data []byte +} + +func (f *fakeStdin) Write(p []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.data = append(f.data, p...) + return len(p), nil +} + +func (f *fakeStdin) Lines() []string { + f.mu.Lock() + defer f.mu.Unlock() + var lines []string + for _, line := range splitLines(string(f.data)) { + if line != "" { + lines = append(lines, line) + } + } + return lines +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i, c := range s { + if c == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +func TestCodexHandleResponseSuccess(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + + // Register a pending request + pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "test"} + c.mu.Lock() + c.pending[1] = pr + c.mu.Unlock() + + c.handleLine(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`) + + res := <-pr.ch + if res.err != nil { + t.Fatalf("expected no error, got %v", res.err) + } + + var parsed map[string]any + if err := json.Unmarshal(res.result, &parsed); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if parsed["ok"] != true { + t.Fatalf("expected ok=true, got %v", parsed["ok"]) + } +} + +func TestCodexHandleResponseError(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + + pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "test"} + c.mu.Lock() + c.pending[1] = pr + c.mu.Unlock() + + c.handleLine(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`) + + res := <-pr.ch + if res.err == nil { + t.Fatal("expected error") + } + if res.result != nil { + t.Fatalf("expected nil result, got %v", res.result) + } +} + +func TestCodexHandleServerRequestAutoApproves(t *testing.T) { + t.Parallel() + + c, fs, _ := newTestCodexClient(t) + + // Command execution approval + c.handleLine(`{"jsonrpc":"2.0","id":10,"method":"item/commandExecution/requestApproval","params":{}}`) + + lines := fs.Lines() + if len(lines) != 1 { + t.Fatalf("expected 1 response, got %d", len(lines)) + } + + var resp map[string]any + if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["id"] != float64(10) { + t.Fatalf("expected id=10, got %v", resp["id"]) + } + result := resp["result"].(map[string]any) + if result["decision"] != "accept" { + t.Fatalf("expected decision=accept, got %v", result["decision"]) + } +} + +func TestCodexHandleServerRequestFileChangeApproval(t *testing.T) { + t.Parallel() + + c, fs, _ := newTestCodexClient(t) + + c.handleLine(`{"jsonrpc":"2.0","id":11,"method":"applyPatchApproval","params":{}}`) + + lines := fs.Lines() + if len(lines) != 1 { + t.Fatalf("expected 1 response, got %d", len(lines)) + } + + var resp map[string]any + if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + result := resp["result"].(map[string]any) + if result["decision"] != "accept" { + t.Fatalf("expected decision=accept, got %v", result["decision"]) + } +} + +func TestCodexLegacyEventTaskStarted(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + var gotStatus bool + c.onMessage = func(msg Message) { + if msg.Type == MessageStatus && msg.Status == "running" { + gotStatus = true + } + } + + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}`) + + if !gotStatus { + t.Fatal("expected status=running message") + } + if !c.turnStarted { + t.Fatal("expected turnStarted=true") + } + if c.notificationProtocol != "legacy" { + t.Fatalf("expected protocol=legacy, got %q", c.notificationProtocol) + } +} + +func TestCodexLegacyEventAgentMessage(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + var gotText string + c.onMessage = func(msg Message) { + if msg.Type == MessageText { + gotText = msg.Content + } + } + + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"agent_message","message":"I found the bug"}}}`) + + if gotText != "I found the bug" { + t.Fatalf("expected text 'I found the bug', got %q", gotText) + } +} + +func TestCodexLegacyEventExecCommand(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"exec_command_begin","call_id":"c1","command":"ls -la"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"exec_command_end","call_id":"c1","output":"total 42"}}}`) + + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if messages[0].Type != MessageToolUse || messages[0].Tool != "exec_command" || messages[0].CallID != "c1" { + t.Fatalf("unexpected begin message: %+v", messages[0]) + } + if messages[1].Type != MessageToolResult || messages[1].CallID != "c1" || messages[1].Output != "total 42" { + t.Fatalf("unexpected end message: %+v", messages[1]) + } +} + +func TestCodexLegacyEventTaskComplete(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + var done bool + c.onTurnDone = func(aborted bool) { + done = true + if aborted { + t.Fatal("expected aborted=false") + } + } + + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_complete"}}}`) + + if !done { + t.Fatal("expected onTurnDone to be called") + } +} + +func TestCodexLegacyEventTurnAborted(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + var abortedResult bool + c.onTurnDone = func(aborted bool) { + abortedResult = aborted + } + + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"turn_aborted"}}}`) + + if !abortedResult { + t.Fatal("expected aborted=true") + } +} + +func TestCodexRawTurnStarted(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + // The zero value "" doesn't match "unknown", so protocol auto-detection + // won't trigger. Set it explicitly as production code would. + c.notificationProtocol = "unknown" + + var gotStatus bool + c.onMessage = func(msg Message) { + if msg.Type == MessageStatus && msg.Status == "running" { + gotStatus = true + } + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"turn-1"}}}`) + + if !gotStatus { + t.Fatal("expected status=running message") + } + if c.notificationProtocol != "raw" { + t.Fatalf("expected protocol=raw, got %q", c.notificationProtocol) + } + if c.turnID != "turn-1" { + t.Fatalf("expected turnID=turn-1, got %q", c.turnID) + } +} + +func TestCodexRawTurnCompleted(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var doneCount int + c.onTurnDone = func(aborted bool) { + doneCount++ + if aborted { + t.Fatal("expected aborted=false") + } + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`) + + if doneCount != 1 { + t.Fatalf("expected onTurnDone called once, got %d", doneCount) + } +} + +func TestCodexRawTurnCompletedDeduplication(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var doneCount int + c.onTurnDone = func(aborted bool) { + doneCount++ + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`) + + if doneCount != 1 { + t.Fatalf("expected deduplication, but onTurnDone called %d times", doneCount) + } +} + +func TestCodexRawTurnCompletedAborted(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var wasAborted bool + c.onTurnDone = func(aborted bool) { + wasAborted = aborted + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-2","status":"cancelled"}}}`) + + if !wasAborted { + t.Fatal("expected aborted=true for cancelled status") + } +} + +func TestCodexRawItemCommandExecution(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"commandExecution","id":"item-1","command":"git status"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"commandExecution","id":"item-1","aggregatedOutput":"on branch main"}}}`) + + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if messages[0].Type != MessageToolUse || messages[0].Tool != "exec_command" || messages[0].Input["command"] != "git status" { + t.Fatalf("unexpected start message: %+v", messages[0]) + } + if messages[1].Type != MessageToolResult || messages[1].Output != "on branch main" { + t.Fatalf("unexpected complete message: %+v", messages[1]) + } +} + +func TestCodexRawItemAgentMessageFinalAnswer(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.turnStarted = true + + var gotText string + var turnDone bool + c.onMessage = func(msg Message) { + if msg.Type == MessageText { + gotText = msg.Content + } + } + c.onTurnDone = func(aborted bool) { + turnDone = true + } + + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg-1","text":"Done!","phase":"final_answer"}}}`) + + if gotText != "Done!" { + t.Fatalf("expected text 'Done!', got %q", gotText) + } + if !turnDone { + t.Fatal("expected onTurnDone for final_answer") + } +} + +func TestCodexRawThreadStatusIdle(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.turnStarted = true + + var turnDone bool + c.onTurnDone = func(aborted bool) { + turnDone = true + if aborted { + t.Fatal("expected aborted=false for idle") + } + } + + c.handleLine(`{"jsonrpc":"2.0","method":"thread/status/changed","params":{"status":{"type":"idle"}}}`) + + if !turnDone { + t.Fatal("expected onTurnDone for idle status") + } +} + +func TestCodexCloseAllPending(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + + pr1 := &pendingRPC{ch: make(chan rpcResult, 1), method: "m1"} + pr2 := &pendingRPC{ch: make(chan rpcResult, 1), method: "m2"} + c.mu.Lock() + c.pending[1] = pr1 + c.pending[2] = pr2 + c.mu.Unlock() + + c.closeAllPending(fmt.Errorf("test error")) + + r1 := <-pr1.ch + if r1.err == nil { + t.Fatal("expected error for pending 1") + } + r2 := <-pr2.ch + if r2.err == nil { + t.Fatal("expected error for pending 2") + } + + c.mu.Lock() + defer c.mu.Unlock() + if len(c.pending) != 0 { + t.Fatalf("expected empty pending map, got %d", len(c.pending)) + } +} + +func TestCodexHandleInvalidJSON(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + // Should not panic + c.handleLine("not json at all") + c.handleLine("") + c.handleLine("{}") +} + +func TestExtractThreadID(t *testing.T) { + t.Parallel() + + data := json.RawMessage(`{"thread":{"id":"t-123"}}`) + got := extractThreadID(data) + if got != "t-123" { + t.Fatalf("expected t-123, got %q", got) + } +} + +func TestExtractThreadIDMissing(t *testing.T) { + t.Parallel() + + got := extractThreadID(json.RawMessage(`{}`)) + if got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestExtractNestedString(t *testing.T) { + t.Parallel() + + m := map[string]any{ + "a": map[string]any{ + "b": "value", + }, + } + got := extractNestedString(m, "a", "b") + if got != "value" { + t.Fatalf("expected 'value', got %q", got) + } +} + +func TestExtractNestedStringMissingKey(t *testing.T) { + t.Parallel() + + m := map[string]any{"a": "flat"} + got := extractNestedString(m, "a", "b") + if got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestNilIfEmpty(t *testing.T) { + t.Parallel() + + if nilIfEmpty("") != nil { + t.Fatal("expected nil for empty string") + } + if nilIfEmpty("hello") != "hello" { + t.Fatal("expected 'hello'") + } +} + +func TestCodexProtocolDetectionLegacyBlocksRaw(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + // First: receive a legacy event -> locks to "legacy" + c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}`) + + if c.notificationProtocol != "legacy" { + t.Fatalf("expected legacy, got %q", c.notificationProtocol) + } + + // Now send a raw notification -> should be ignored + messagesBefore := len(messages) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"turn-1"}}}`) + + if len(messages) != messagesBefore { + t.Fatal("raw notification should be ignored in legacy mode") + } +} From b7728ff802caca04ef6cbe7d7253f630ce0fea20 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:25:33 +0800 Subject: [PATCH 04/16] docs: expand CLAUDE.md with architecture details and update lockfile Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 88 +++++++++++++++++++++++++++++++++++++++++--------- pnpm-lock.yaml | 9 ++++++ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 57c1342a..840b5f70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ # CLAUDE.md -This file gives coding agents high-signal guidance for this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## 1. Project Context +## Project Context Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens. @@ -10,15 +10,56 @@ Multica is an AI-native task management platform — like Linear, but with AI ag - Supports local (daemon) and cloud agent runtimes - Built for 2-10 person AI-native teams -## 2. Architecture +## Architecture **Polyglot monorepo** — Go backend + TypeScript frontend. -- `server/` — Go backend (Chi + sqlc + gorilla/websocket) -- `apps/web/` — Next.js 16 frontend +- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time) +- `apps/web/` — Next.js 16 frontend (App Router) - `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils) -## 3. Core Workflow Commands +### Data Flow + +``` +Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL +Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService +``` + +### Backend Structure (`server/`) + +- **Entry points** (`cmd/`): `server` (HTTP API), `daemon` (local agent runtime), `migrate`, `seed` +- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`. +- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO. +- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found. +- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition. +- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. +- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). + +### Frontend Structure (`apps/web/`) + +- **App Router layout groups**: `(auth)/` for login, `(dashboard)/` for protected routes +- **Auth context** (`lib/auth-context.tsx`): Global provider for user, workspace, members, agents. Hydrates from localStorage. Provides actor lookup helpers (`getMemberName`, `getAgentName`, `getActorName`). +- **WebSocket context** (`lib/ws-context.tsx`): Wraps `WSClient` from SDK. `useWSEvent()` hook auto-subscribes/unsubscribes. +- **API client** (`lib/api.ts`): Singleton `ApiClient` from `@multica/sdk`, initialized from localStorage. +- **State**: Zustand stores (`@multica/store`) for issues, agents, inbox. WebSocket events keep stores in sync without re-fetching. + +### Key Packages + +- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here. +- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*). +- **`@multica/store`**: Zustand stores — simple arrays with add/update/remove. No persistence; memory only. +- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown. +- **`@multica/hooks`**: `useRealtime()` (WS → store sync), `useIssues()`, `useAgents()`, `useInbox()` (fetch + cache). + +### Multi-tenancy + +All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace. + +### Agent Assignees + +Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon). + +## Commands ```bash # One-click setup & run @@ -38,17 +79,34 @@ pnpm test # TS tests (Vitest) make dev # Run Go server (port 8080) make daemon # Run local daemon make test # Go tests -make sqlc # Regenerate sqlc code +make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/ make migrate-up # Run database migrations make migrate-down # Rollback migrations -make seed # Seed example data + +# Run a single Go test +cd server && go test ./internal/handler/ -run TestName + +# Run a single TS test +pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts + +# Run a single E2E test (requires backend + frontend running) +pnpm exec playwright test e2e/tests/specific-test.spec.ts # Infrastructure docker compose up -d # Start PostgreSQL docker compose down # Stop PostgreSQL ``` -## 4. Coding Rules +### Worktree Support + +For isolated feature testing with a separate database: +```bash +make worktree-env # Generate .env.worktree with unique DB/ports +make setup-worktree # Setup using .env.worktree +make start-worktree # Start using .env.worktree +``` + +## Coding Rules - TypeScript strict mode is enabled; keep types explicit. - Go code follows standard Go conventions (gofmt, go vet). @@ -59,19 +117,19 @@ docker compose down # Stop PostgreSQL - Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about. - Avoid broad refactors unless required by the task. -## 5. UI/UX Rules +## UI/UX Rules - Prefer `packages/ui` shadcn components over custom implementations. - Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. - Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency. - When unsure about interaction or state design, ask — the user will provide direction. -## 6. Testing Rules +## Testing Rules - **TypeScript**: Vitest. Mock external/third-party dependencies only. - **Go**: Standard `go test`. Use testcontainers or test database for DB tests. -## 7. Commit Rules +## Commit Rules - Use atomic commits grouped by logical intent. - Conventional format: @@ -82,7 +140,7 @@ docker compose down # Stop PostgreSQL - `test(scope): ...` - `chore(scope): ...` -## 8. Minimum Pre-Push Checks +## Minimum Pre-Push Checks ```bash make check # Runs all checks: typecheck, unit tests, Go tests, E2E @@ -96,7 +154,7 @@ make test # Go tests only pnpm exec playwright test # E2E only (requires backend + frontend running) ``` -## 9. AI Agent Verification Loop +## AI Agent Verification Loop After writing or modifying code, always run the full verification pipeline: @@ -119,7 +177,7 @@ This runs all checks in sequence: **Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete. -## 10. E2E Test Patterns +## E2E Test Patterns E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 633a5bc9..d2c1d540 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,15 @@ importers: specifier: ^4.1.0 version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)) + packages/agent-sdk: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.5.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/hooks: dependencies: '@multica/sdk': From d1e4228aa79e762e35d848c4310fa630cd0af66c Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:26:53 +0800 Subject: [PATCH 05/16] chore: add server/daemon binary to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 34311237..21c3fd76 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ coverage server/bin/ server/tmp/ server/migrate +server/daemon # Test artifacts test-results/ From f2228e4f22482bad2106e61d2a6bd411c9e72f83 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:36:09 +0800 Subject: [PATCH 06/16] refactor(daemon): rename DefaultWorkdir to ReposRoot for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MULTICA_AGENT_WORKDIR → MULTICA_REPOS_ROOT to make it clear this is the parent directory containing all repos, not a single project workdir. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/daemon/daemon.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/cmd/daemon/daemon.go b/server/cmd/daemon/daemon.go index 71f1c864..dfea6bc5 100644 --- a/server/cmd/daemon/daemon.go +++ b/server/cmd/daemon/daemon.go @@ -42,7 +42,7 @@ type config struct { DeviceName string RuntimeName string Agents map[string]agentEntry // "claude" -> entry, "codex" -> entry - DefaultWorkdir string + ReposRoot string // parent directory containing all repos PollInterval time.Duration HeartbeatInterval time.Duration AgentTimeout time.Duration @@ -175,16 +175,16 @@ func loadConfig() (config, error) { host = "local-machine" } - defaultWorkdir := strings.TrimSpace(os.Getenv("MULTICA_AGENT_WORKDIR")) - if defaultWorkdir == "" { - defaultWorkdir, err = os.Getwd() + reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT")) + if reposRoot == "" { + reposRoot, err = os.Getwd() if err != nil { return config{}, fmt.Errorf("resolve working directory: %w", err) } } - defaultWorkdir, err = filepath.Abs(defaultWorkdir) + reposRoot, err = filepath.Abs(reposRoot) if err != nil { - return config{}, fmt.Errorf("resolve absolute workdir: %w", err) + return config{}, fmt.Errorf("resolve absolute repos root: %w", err) } pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", defaultPollInterval) @@ -208,7 +208,7 @@ func loadConfig() (config, error) { DeviceName: envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host), RuntimeName: envOrDefault("MULTICA_AGENT_RUNTIME_NAME", defaultRuntimeName), Agents: agents, - DefaultWorkdir: defaultWorkdir, + ReposRoot: reposRoot, PollInterval: pollInterval, HeartbeatInterval: heartbeatInterval, AgentTimeout: agentTimeout, @@ -228,8 +228,8 @@ func (d *daemon) run(ctx context.Context) error { for name := range d.cfg.Agents { agentNames = append(agentNames, name) } - d.logger.Printf("starting daemon agents=%v workspace=%s server=%s workdir=%s", - agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.DefaultWorkdir) + d.logger.Printf("starting daemon agents=%v workspace=%s server=%s repos_root=%s", + agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.ReposRoot) if strings.TrimSpace(d.cfg.WorkspaceID) == "" { workspaceID, err := d.ensurePaired(ctx) @@ -460,7 +460,7 @@ func (d *daemon) runTask(ctx context.Context, task daemonTask) (taskResult, erro return taskResult{}, fmt.Errorf("no agent configured for provider %q", provider) } - workdir, err := resolveTaskWorkdir(d.cfg.DefaultWorkdir, task.Context.Issue.Repository) + workdir, err := resolveTaskWorkdir(d.cfg.ReposRoot, task.Context.Issue.Repository) if err != nil { return taskResult{}, err } @@ -581,8 +581,8 @@ func buildPrompt(task daemonTask, workdir string) string { return b.String() } -func resolveTaskWorkdir(defaultWorkdir string, repo *daemonRepoRef) (string, error) { - base := defaultWorkdir +func resolveTaskWorkdir(reposRoot string, repo *daemonRepoRef) (string, error) { + base := reposRoot if repo == nil || strings.TrimSpace(repo.Path) == "" { return base, nil } From edbe474807f4462f35f58b76d46088b0f33a87ed Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:37:52 +0800 Subject: [PATCH 07/16] chore: gitignore .claude/ and dist-electron Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 21c3fd76..95713cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ out .turbo build bin +dist-electron *.tsbuildinfo # env @@ -31,6 +32,9 @@ apps/web/test-results/ # context (agent workspace) .context +# local settings +.claude/ + # platform specific *.dmg *.app From 41b9698dbf4fccf6ff3469faabfe605f02adc901 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 14:41:38 +0800 Subject: [PATCH 08/16] chore: update Makefile daemon target to use MULTICA_REPOS_ROOT Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9fe220c2..31d3b693 100644 --- a/Makefile +++ b/Makefile @@ -113,7 +113,7 @@ dev: cd server && go run ./cmd/server daemon: - cd server && MULTICA_CODEX_WORKDIR="${MULTICA_CODEX_WORKDIR:-$(abspath .)}" go run ./cmd/daemon + cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/daemon build: cd server && go build -o bin/server ./cmd/server From 707b5ac6e759e4f1819d1961bd997ecfb085e06c Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 15:44:49 +0800 Subject: [PATCH 09/16] refactor(cli): unify daemon into multica-cli binary with cobra subcommands Extract daemon logic from cmd/daemon/ into internal/daemon/ package and create a new unified CLI entry point at cmd/multica/ using cobra. The CLI supports `daemon` as a long-running subcommand plus ctrl subcommands for agent/runtime management, config, status, and version. Server, migrate, and seed binaries remain unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + Makefile | 9 +- server/cmd/daemon/daemon.go | 816 ------------------ server/cmd/daemon/main.go | 27 - server/cmd/multica/cmd_agent.go | 172 ++++ server/cmd/multica/cmd_config.go | 79 ++ server/cmd/multica/cmd_daemon.go | 77 ++ server/cmd/multica/cmd_runtime.go | 63 ++ server/cmd/multica/cmd_status.go | 36 + server/cmd/multica/cmd_version.go | 15 + server/cmd/multica/main.go | 38 + server/go.mod | 8 +- server/go.sum | 18 +- server/internal/cli/client.go | 96 +++ server/internal/cli/config.go | 65 ++ server/internal/cli/flags.go | 21 + server/internal/cli/output.go | 26 + server/internal/daemon/client.go | 141 +++ server/internal/daemon/config.go | 254 ++++++ server/internal/daemon/daemon.go | 325 +++++++ .../{cmd => internal}/daemon/daemon_test.go | 18 +- server/internal/daemon/helpers.go | 46 + server/internal/daemon/prompt.go | 93 ++ server/internal/daemon/types.go | 89 ++ 24 files changed, 1673 insertions(+), 860 deletions(-) delete mode 100644 server/cmd/daemon/daemon.go delete mode 100644 server/cmd/daemon/main.go create mode 100644 server/cmd/multica/cmd_agent.go create mode 100644 server/cmd/multica/cmd_config.go create mode 100644 server/cmd/multica/cmd_daemon.go create mode 100644 server/cmd/multica/cmd_runtime.go create mode 100644 server/cmd/multica/cmd_status.go create mode 100644 server/cmd/multica/cmd_version.go create mode 100644 server/cmd/multica/main.go create mode 100644 server/internal/cli/client.go create mode 100644 server/internal/cli/config.go create mode 100644 server/internal/cli/flags.go create mode 100644 server/internal/cli/output.go create mode 100644 server/internal/daemon/client.go create mode 100644 server/internal/daemon/config.go create mode 100644 server/internal/daemon/daemon.go rename server/{cmd => internal}/daemon/daemon_test.go (75%) create mode 100644 server/internal/daemon/helpers.go create mode 100644 server/internal/daemon/prompt.go create mode 100644 server/internal/daemon/types.go diff --git a/.gitignore b/.gitignore index 95713cb0..d902de1c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ server/bin/ server/tmp/ server/migrate server/daemon +server/multica-cli # Test artifacts test-results/ diff --git a/Makefile b/Makefile index 31d3b693..d5c0b55b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree +.PHONY: dev daemon cli build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree MAIN_ENV_FILE ?= .env WORKTREE_ENV_FILE ?= .env.worktree @@ -113,11 +113,14 @@ dev: cd server && go run ./cmd/server daemon: - cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/daemon + cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/multica daemon + +cli: + cd server && go run ./cmd/multica $(ARGS) build: cd server && go build -o bin/server ./cmd/server - cd server && go build -o bin/daemon ./cmd/daemon + cd server && go build -o bin/multica-cli ./cmd/multica test: cd server && go test ./... diff --git a/server/cmd/daemon/daemon.go b/server/cmd/daemon/daemon.go deleted file mode 100644 index dfea6bc5..00000000 --- a/server/cmd/daemon/daemon.go +++ /dev/null @@ -1,816 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/multica-ai/multica/server/pkg/agent" -) - -const ( - defaultServerURL = "ws://localhost:8080/ws" - defaultDaemonConfigPath = ".multica/daemon.json" - defaultPollInterval = 3 * time.Second - defaultHeartbeatInterval = 15 * time.Second - defaultAgentTimeout = 20 * time.Minute - defaultRuntimeName = "Local Agent" -) - -// agentEntry describes a single available agent CLI. -type agentEntry struct { - Path string // path to CLI binary - Model string // model override (optional) -} - -type config struct { - ServerBaseURL string - ConfigPath string - WorkspaceID string - DaemonID string - DeviceName string - RuntimeName string - Agents map[string]agentEntry // "claude" -> entry, "codex" -> entry - ReposRoot string // parent directory containing all repos - PollInterval time.Duration - HeartbeatInterval time.Duration - AgentTimeout time.Duration -} - -type daemon struct { - cfg config - client *daemonClient - logger *log.Logger -} - -type daemonClient struct { - baseURL string - client *http.Client -} - -type daemonRuntime struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - Status string `json:"status"` -} - -type daemonPairingSession struct { - Token string `json:"token"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` - WorkspaceID *string `json:"workspace_id"` - Status string `json:"status"` - ApprovedAt *string `json:"approved_at"` - ClaimedAt *string `json:"claimed_at"` - ExpiresAt string `json:"expires_at"` - LinkURL *string `json:"link_url"` -} - -type daemonPersistedConfig struct { - WorkspaceID string `json:"workspace_id"` -} - -type daemonTask struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - IssueID string `json:"issue_id"` - Context daemonTaskContext `json:"context"` -} - -type daemonTaskContext struct { - Issue daemonIssueContext `json:"issue"` - Agent daemonAgentContext `json:"agent"` - Runtime daemonRuntimeContext `json:"runtime"` -} - -type daemonIssueContext struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - AcceptanceCriteria []string `json:"acceptance_criteria"` - ContextRefs []string `json:"context_refs"` - Repository *daemonRepoRef `json:"repository"` -} - -type daemonAgentContext struct { - ID string `json:"id"` - Name string `json:"name"` - Skills string `json:"skills"` -} - -type daemonRuntimeContext struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - DeviceInfo string `json:"device_info"` -} - -type daemonRepoRef struct { - URL string `json:"url"` - Branch string `json:"branch"` - Path string `json:"path"` -} - -type taskResult struct { - Status string `json:"status"` - Comment string `json:"comment"` -} - -func loadConfig() (config, error) { - serverBaseURL, err := normalizeServerBaseURL(envOrDefault("MULTICA_SERVER_URL", defaultServerURL)) - if err != nil { - return config{}, err - } - - configPath, err := resolveDaemonConfigPath(strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG"))) - if err != nil { - return config{}, err - } - persisted, err := loadPersistedDaemonConfig(configPath) - if err != nil { - return config{}, err - } - workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID")) - if workspaceID == "" { - workspaceID = persisted.WorkspaceID - } - - // 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")), - } - } - if len(agents) == 0 { - return config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH") - } - - host, err := os.Hostname() - if err != nil || strings.TrimSpace(host) == "" { - host = "local-machine" - } - - reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT")) - if reposRoot == "" { - reposRoot, err = os.Getwd() - if err != nil { - return config{}, fmt.Errorf("resolve working directory: %w", err) - } - } - reposRoot, err = filepath.Abs(reposRoot) - if err != nil { - return config{}, fmt.Errorf("resolve absolute repos root: %w", err) - } - - pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", defaultPollInterval) - if err != nil { - return config{}, err - } - heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", defaultHeartbeatInterval) - if err != nil { - return config{}, err - } - agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", defaultAgentTimeout) - if err != nil { - return config{}, err - } - - return config{ - ServerBaseURL: serverBaseURL, - ConfigPath: configPath, - WorkspaceID: workspaceID, - DaemonID: envOrDefault("MULTICA_DAEMON_ID", host), - DeviceName: envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host), - RuntimeName: envOrDefault("MULTICA_AGENT_RUNTIME_NAME", defaultRuntimeName), - Agents: agents, - ReposRoot: reposRoot, - PollInterval: pollInterval, - HeartbeatInterval: heartbeatInterval, - AgentTimeout: agentTimeout, - }, nil -} - -func newDaemon(cfg config, logger *log.Logger) *daemon { - return &daemon{ - cfg: cfg, - client: &daemonClient{baseURL: cfg.ServerBaseURL, client: &http.Client{Timeout: 30 * time.Second}}, - logger: logger, - } -} - -func (d *daemon) run(ctx context.Context) error { - agentNames := make([]string, 0, len(d.cfg.Agents)) - for name := range d.cfg.Agents { - agentNames = append(agentNames, name) - } - d.logger.Printf("starting daemon agents=%v workspace=%s server=%s repos_root=%s", - agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.ReposRoot) - - if strings.TrimSpace(d.cfg.WorkspaceID) == "" { - workspaceID, err := d.ensurePaired(ctx) - if err != nil { - return err - } - d.cfg.WorkspaceID = workspaceID - d.logger.Printf("pairing completed for workspace=%s", workspaceID) - } - - runtimes, err := d.registerRuntimes(ctx) - if err != nil { - return err - } - runtimeIDs := make([]string, 0, len(runtimes)) - for _, rt := range runtimes { - d.logger.Printf("registered runtime id=%s provider=%s status=%s", rt.ID, rt.Provider, rt.Status) - runtimeIDs = append(runtimeIDs, rt.ID) - } - - go d.heartbeatLoop(ctx, runtimeIDs) - return d.pollLoop(ctx, runtimeIDs) -} - -func (d *daemon) registerRuntimes(ctx context.Context) ([]daemonRuntime, error) { - var runtimes []map[string]string - for name, entry := range d.cfg.Agents { - version, err := agent.DetectVersion(ctx, entry.Path) - if err != nil { - d.logger.Printf("skip registering %s: %v", name, err) - continue - } - runtimes = append(runtimes, map[string]string{ - "name": fmt.Sprintf("Local %s", strings.ToUpper(name[:1])+name[1:]), - "type": name, - "version": version, - "status": "online", - }) - } - if len(runtimes) == 0 { - return nil, fmt.Errorf("no agent runtimes could be registered") - } - - req := map[string]any{ - "workspace_id": d.cfg.WorkspaceID, - "daemon_id": d.cfg.DaemonID, - "device_name": d.cfg.DeviceName, - "runtimes": runtimes, - } - - var resp struct { - Runtimes []daemonRuntime `json:"runtimes"` - } - if err := d.client.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil { - return nil, fmt.Errorf("register runtimes: %w", err) - } - if len(resp.Runtimes) == 0 { - return nil, fmt.Errorf("register runtimes: empty response") - } - return resp.Runtimes, nil -} - -func (d *daemon) ensurePaired(ctx context.Context) (string, error) { - // Use a deterministic agent for the pairing session metadata (prefer codex for backward compat). - var firstName string - var firstEntry agentEntry - for _, preferred := range []string{"codex", "claude"} { - if entry, ok := d.cfg.Agents[preferred]; ok { - firstName = preferred - firstEntry = entry - break - } - } - version, err := agent.DetectVersion(ctx, firstEntry.Path) - if err != nil { - return "", err - } - - session, err := d.client.createPairingSession(ctx, map[string]string{ - "daemon_id": d.cfg.DaemonID, - "device_name": d.cfg.DeviceName, - "runtime_name": d.cfg.RuntimeName, - "runtime_type": firstName, - "runtime_version": version, - }) - if err != nil { - return "", fmt.Errorf("create pairing session: %w", err) - } - if session.LinkURL != nil { - d.logger.Printf("open this link to pair the daemon: %s", *session.LinkURL) - } else { - d.logger.Printf("pairing session created: %s", session.Token) - } - - for { - select { - case <-ctx.Done(): - return "", ctx.Err() - default: - } - - current, err := d.client.getPairingSession(ctx, session.Token) - if err != nil { - return "", fmt.Errorf("poll pairing session: %w", err) - } - - switch current.Status { - case "approved", "claimed": - if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" { - return "", fmt.Errorf("pairing session approved without workspace") - } - if err := savePersistedDaemonConfig(d.cfg.ConfigPath, daemonPersistedConfig{ - WorkspaceID: strings.TrimSpace(*current.WorkspaceID), - }); err != nil { - return "", err - } - if current.Status != "claimed" { - if _, err := d.client.claimPairingSession(ctx, current.Token); err != nil { - return "", fmt.Errorf("claim pairing session: %w", err) - } - } - return strings.TrimSpace(*current.WorkspaceID), nil - case "expired": - return "", fmt.Errorf("pairing session expired before approval") - } - - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { - return "", err - } - } -} - -func (d *daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { - ticker := time.NewTicker(d.cfg.HeartbeatInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - for _, rid := range runtimeIDs { - err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ - "runtime_id": rid, - }, nil) - if err != nil { - d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err) - } - } - } - } -} - -func (d *daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { - pollOffset := 0 - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - claimed := false - n := len(runtimeIDs) - for i := 0; i < n; i++ { - rid := runtimeIDs[(pollOffset+i)%n] - task, err := d.client.claimTask(ctx, rid) - if err != nil { - d.logger.Printf("claim task failed for runtime %s: %v", rid, err) - continue - } - if task != nil { - d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title) - d.handleTask(ctx, *task) - claimed = true - pollOffset = (pollOffset + i + 1) % n - break - } - } - - if !claimed { - pollOffset = (pollOffset + 1) % n - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { - return err - } - } - } -} - -func (d *daemon) handleTask(ctx context.Context, task daemonTask) { - provider := task.Context.Runtime.Provider - d.logger.Printf("picked task=%s issue=%s provider=%s title=%q", task.ID, task.IssueID, provider, task.Context.Issue.Title) - - if err := d.client.startTask(ctx, task.ID); err != nil { - d.logger.Printf("start task %s failed: %v", task.ID, err) - return - } - - _ = d.client.reportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2) - - result, err := d.runTask(ctx, task) - if err != nil { - d.logger.Printf("task %s failed: %v", task.ID, err) - if failErr := d.client.failTask(ctx, task.ID, err.Error()); failErr != nil { - d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr) - } - return - } - - _ = d.client.reportProgress(ctx, task.ID, "Finishing task", 2, 2) - - switch result.Status { - case "blocked": - if err := d.client.failTask(ctx, task.ID, result.Comment); err != nil { - d.logger.Printf("report blocked task %s failed: %v", task.ID, err) - } - default: - if err := d.client.completeTask(ctx, task.ID, result.Comment); err != nil { - d.logger.Printf("complete task %s failed: %v", task.ID, err) - } - } -} - -func (d *daemon) runTask(ctx context.Context, task daemonTask) (taskResult, error) { - provider := task.Context.Runtime.Provider - entry, ok := d.cfg.Agents[provider] - if !ok { - return taskResult{}, fmt.Errorf("no agent configured for provider %q", provider) - } - - workdir, err := resolveTaskWorkdir(d.cfg.ReposRoot, task.Context.Issue.Repository) - if err != nil { - return taskResult{}, err - } - - prompt := buildPrompt(task, workdir) - - backend, err := agent.New(provider, agent.Config{ - ExecutablePath: entry.Path, - Logger: d.logger, - }) - if err != nil { - return taskResult{}, fmt.Errorf("create agent backend: %w", err) - } - - d.logger.Printf( - "starting %s task=%s workdir=%s model=%s timeout=%s", - provider, task.ID, workdir, entry.Model, d.cfg.AgentTimeout, - ) - - session, err := backend.Execute(ctx, prompt, agent.ExecOptions{ - Cwd: workdir, - Model: entry.Model, - Timeout: d.cfg.AgentTimeout, - }) - if err != nil { - return taskResult{}, err - } - - // Drain message channel (log tool uses, ignore text since Result has output) - go func() { - for msg := range session.Messages { - switch msg.Type { - case agent.MessageToolUse: - d.logger.Printf("[%s] tool-use: %s (call=%s)", provider, msg.Tool, msg.CallID) - case agent.MessageError: - d.logger.Printf("[%s] error: %s", provider, msg.Content) - } - } - }() - - result := <-session.Result - - switch result.Status { - case "completed": - if result.Output == "" { - return taskResult{}, fmt.Errorf("%s returned empty output", provider) - } - return taskResult{Status: "completed", Comment: result.Output}, nil - case "timeout": - return taskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout) - default: - errMsg := result.Error - if errMsg == "" { - errMsg = fmt.Sprintf("%s execution %s", provider, result.Status) - } - return taskResult{Status: "blocked", Comment: errMsg}, nil - } -} - -func buildPrompt(task daemonTask, workdir string) string { - var b strings.Builder - b.WriteString("You are running as a local coding agent for a Multica workspace.\n") - b.WriteString("Complete the assigned issue using the local environment.\n") - b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n") - b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n") - - fmt.Fprintf(&b, "Working directory: %s\n", workdir) - fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name) - fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title) - - if task.Context.Issue.Description != "" { - b.WriteString("Issue description:\n") - b.WriteString(task.Context.Issue.Description) - b.WriteString("\n\n") - } - - if len(task.Context.Issue.AcceptanceCriteria) > 0 { - b.WriteString("Acceptance criteria:\n") - for _, item := range task.Context.Issue.AcceptanceCriteria { - fmt.Fprintf(&b, "- %s\n", item) - } - b.WriteString("\n") - } - - if len(task.Context.Issue.ContextRefs) > 0 { - b.WriteString("Context refs:\n") - for _, item := range task.Context.Issue.ContextRefs { - fmt.Fprintf(&b, "- %s\n", item) - } - b.WriteString("\n") - } - - if repo := task.Context.Issue.Repository; repo != nil { - b.WriteString("Repository context:\n") - if repo.URL != "" { - fmt.Fprintf(&b, "- url: %s\n", repo.URL) - } - if repo.Branch != "" { - fmt.Fprintf(&b, "- branch: %s\n", repo.Branch) - } - if repo.Path != "" { - fmt.Fprintf(&b, "- path: %s\n", repo.Path) - } - b.WriteString("\n") - } - - if task.Context.Agent.Skills != "" { - b.WriteString("Agent skills/instructions:\n") - b.WriteString(task.Context.Agent.Skills) - b.WriteString("\n\n") - } - - b.WriteString("Comment requirements:\n") - b.WriteString("- Lead with the outcome.\n") - b.WriteString("- Mention concrete files or commands if you changed anything.\n") - b.WriteString("- Mention blockers or follow-up actions if relevant.\n") - - return b.String() -} - -func resolveTaskWorkdir(reposRoot string, repo *daemonRepoRef) (string, error) { - base := reposRoot - if repo == nil || strings.TrimSpace(repo.Path) == "" { - return base, nil - } - - path := strings.TrimSpace(repo.Path) - if !filepath.IsAbs(path) { - path = filepath.Join(base, path) - } - path = filepath.Clean(path) - - info, err := os.Stat(path) - if err != nil { - return "", fmt.Errorf("repository path not found: %s", path) - } - if !info.IsDir() { - return "", fmt.Errorf("repository path is not a directory: %s", path) - } - return path, nil -} - -func resolveDaemonConfigPath(raw string) (string, error) { - if raw != "" { - return filepath.Abs(raw) - } - - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve daemon config path: %w", err) - } - return filepath.Join(home, defaultDaemonConfigPath), nil -} - -func loadPersistedDaemonConfig(path string) (daemonPersistedConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return daemonPersistedConfig{}, nil - } - return daemonPersistedConfig{}, fmt.Errorf("read daemon config: %w", err) - } - - var cfg daemonPersistedConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return daemonPersistedConfig{}, fmt.Errorf("parse daemon config: %w", err) - } - return cfg, nil -} - -func savePersistedDaemonConfig(path string, cfg daemonPersistedConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create daemon config directory: %w", err) - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("encode daemon config: %w", err) - } - if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { - return fmt.Errorf("write daemon config: %w", err) - } - return nil -} - -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 -} - -func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback, nil - } - d, err := time.ParseDuration(value) - if err != nil { - return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err) - } - return d, nil -} - -func envOrDefault(key, fallback string) string { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback - } - return value -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - timer := time.NewTimer(d) - defer timer.Stop() - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func (c *daemonClient) claimTask(ctx context.Context, runtimeID string) (*daemonTask, error) { - var resp struct { - Task *daemonTask `json:"task"` - } - if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil { - return nil, err - } - return resp.Task, nil -} - -func (c *daemonClient) createPairingSession(ctx context.Context, req map[string]string) (daemonPairingSession, error) { - var resp daemonPairingSession - if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil { - return daemonPairingSession{}, err - } - return resp, nil -} - -func (c *daemonClient) getPairingSession(ctx context.Context, token string) (daemonPairingSession, error) { - var resp daemonPairingSession - if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil { - return daemonPairingSession{}, err - } - return resp, nil -} - -func (c *daemonClient) claimPairingSession(ctx context.Context, token string) (daemonPairingSession, error) { - var resp daemonPairingSession - if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil { - return daemonPairingSession{}, err - } - return resp, nil -} - -func (c *daemonClient) startTask(ctx context.Context, taskID string) error { - return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil) -} - -func (c *daemonClient) reportProgress(ctx context.Context, taskID, summary string, step, total int) error { - return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{ - "summary": summary, - "step": step, - "total": total, - }, nil) -} - -func (c *daemonClient) completeTask(ctx context.Context, taskID, output string) error { - return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{ - "output": output, - }, nil) -} - -func (c *daemonClient) failTask(ctx context.Context, taskID, errMsg string) error { - return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{ - "error": errMsg, - }, nil) -} - -func (c *daemonClient) postJSON(ctx context.Context, path string, reqBody any, respBody any) error { - var body io.Reader - if reqBody != nil { - data, err := json.Marshal(reqBody) - if err != nil { - return err - } - body = bytes.NewReader(data) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("%s %s returned %d: %s", http.MethodPost, path, resp.StatusCode, strings.TrimSpace(string(data))) - } - if respBody == nil { - io.Copy(io.Discard, resp.Body) - return nil - } - return json.NewDecoder(resp.Body).Decode(respBody) -} - -func (c *daemonClient) getJSON(ctx context.Context, path string, respBody any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) - if err != nil { - return err - } - - resp, err := c.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("%s %s returned %d: %s", http.MethodGet, path, resp.StatusCode, strings.TrimSpace(string(data))) - } - if respBody == nil { - io.Copy(io.Discard, resp.Body) - return nil - } - return json.NewDecoder(resp.Body).Decode(respBody) -} - diff --git a/server/cmd/daemon/main.go b/server/cmd/daemon/main.go deleted file mode 100644 index 11d9fd93..00000000 --- a/server/cmd/daemon/main.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "os" - "os/signal" - "syscall" -) - -func main() { - cfg, err := loadConfig() - if err != nil { - log.Fatal(err) - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags) - d := newDaemon(cfg, logger) - - if err := d.run(ctx); err != nil && !errors.Is(err, context.Canceled) { - logger.Fatal(err) - } -} diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go new file mode 100644 index 00000000..69fcaf60 --- /dev/null +++ b/server/cmd/multica/cmd_agent.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var agentCmd = &cobra.Command{ + Use: "agent", + Short: "Manage agents", +} + +var agentListCmd = &cobra.Command{ + Use: "list", + Short: "List agents in the workspace", + RunE: runAgentList, +} + +var agentGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get agent details", + Args: cobra.ExactArgs(1), + RunE: runAgentGet, +} + +var agentDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentDelete, +} + +var agentStopCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop an agent (set status to offline)", + Args: cobra.ExactArgs(1), + RunE: runAgentStop, +} + +func init() { + agentCmd.AddCommand(agentListCmd) + agentCmd.AddCommand(agentGetCmd) + agentCmd.AddCommand(agentDeleteCmd) + agentCmd.AddCommand(agentStopCmd) + + agentListCmd.Flags().String("output", "table", "Output format: table or json") +} + +func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { + serverURL := resolveServerURL(cmd) + workspaceID := resolveWorkspaceID(cmd) + + if serverURL == "" { + return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica-cli config set server_url '") + } + + return cli.NewAPIClient(serverURL, workspaceID), nil +} + +func resolveServerURL(cmd *cobra.Command) string { + val := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "") + if val != "" { + return val + } + cfg, err := cli.LoadCLIConfig() + if err != nil { + return "http://localhost:8080" + } + if cfg.ServerURL != "" { + return cfg.ServerURL + } + return "http://localhost:8080" +} + +func resolveWorkspaceID(cmd *cobra.Command) string { + val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "") + if val != "" { + return val + } + cfg, _ := cli.LoadCLIConfig() + return cfg.WorkspaceID +} + +func runAgentList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var agents []map[string]any + path := "/api/agents" + if client.WorkspaceID != "" { + path += "?workspace_id=" + client.WorkspaceID + } + if err := client.GetJSON(ctx, path, &agents); err != nil { + return fmt.Errorf("list agents: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, agents) + } + + headers := []string{"ID", "NAME", "STATUS", "RUNTIME"} + rows := make([][]string, 0, len(agents)) + for _, a := range agents { + rows = append(rows, []string{ + strVal(a, "id"), + strVal(a, "name"), + strVal(a, "status"), + strVal(a, "runtime_mode"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runAgentGet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var agent map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil { + return fmt.Errorf("get agent: %w", err) + } + + return cli.PrintJSON(os.Stdout, agent) +} + +func runAgentDelete(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.DeleteJSON(ctx, "/api/agents/"+args[0]); err != nil { + return fmt.Errorf("delete agent: %w", err) + } + + fmt.Fprintf(os.Stderr, "Agent %s deleted.\n", args[0]) + return nil +} + +func runAgentStop(cmd *cobra.Command, args []string) error { + // TODO: implement agent stop (PUT /api/agents/{id} with status=offline) + return fmt.Errorf("agent stop is not yet implemented") +} + +func strVal(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} diff --git a/server/cmd/multica/cmd_config.go b/server/cmd/multica/cmd_config.go new file mode 100644 index 00000000..a9b5d1f5 --- /dev/null +++ b/server/cmd/multica/cmd_config.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration", +} + +var configShowCmd = &cobra.Command{ + Use: "show", + Short: "Show current CLI configuration", + RunE: runConfigShow, +} + +var configSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a CLI configuration value", + Long: "Supported keys: server_url, workspace_id", + Args: cobra.ExactArgs(2), + RunE: runConfigSet, +} + +func init() { + configCmd.AddCommand(configShowCmd) + configCmd.AddCommand(configSetCmd) +} + +func runConfigShow(_ *cobra.Command, _ []string) error { + cfg, err := cli.LoadCLIConfig() + if err != nil { + return err + } + + path, _ := cli.CLIConfigPath() + fmt.Fprintf(os.Stdout, "Config file: %s\n", path) + fmt.Fprintf(os.Stdout, "server_url: %s\n", valueOrDefault(cfg.ServerURL, "(not set)")) + fmt.Fprintf(os.Stdout, "workspace_id: %s\n", valueOrDefault(cfg.WorkspaceID, "(not set)")) + return nil +} + +func runConfigSet(_ *cobra.Command, args []string) error { + key, value := args[0], args[1] + + cfg, err := cli.LoadCLIConfig() + if err != nil { + return err + } + + switch key { + case "server_url": + cfg.ServerURL = value + case "workspace_id": + cfg.WorkspaceID = value + default: + return fmt.Errorf("unknown config key %q (supported: server_url, workspace_id)", key) + } + + if err := cli.SaveCLIConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Set %s = %s\n", key, value) + return nil +} + +func valueOrDefault(v, fallback string) string { + if v == "" { + return fallback + } + return v +} diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go new file mode 100644 index 00000000..e70f2a5f --- /dev/null +++ b/server/cmd/multica/cmd_daemon.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "errors" + "log" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" + "github.com/multica-ai/multica/server/internal/daemon" +) + +var daemonCmd = &cobra.Command{ + Use: "daemon", + Short: "Run the local agent runtime daemon", + Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).", + RunE: runDaemon, +} + +func init() { + f := daemonCmd.Flags() + f.String("repos-root", "", "Base directory for task repositories (env: MULTICA_REPOS_ROOT)") + f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)") + f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)") + f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)") + f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)") + f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)") + f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)") + f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)") +} + +func runDaemon(cmd *cobra.Command, _ []string) error { + overrides := daemon.Overrides{ + ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""), + WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""), + ReposRoot: flagString(cmd, "repos-root"), + ConfigPath: flagString(cmd, "config-path"), + DaemonID: flagString(cmd, "daemon-id"), + DeviceName: flagString(cmd, "device-name"), + RuntimeName: flagString(cmd, "runtime-name"), + } + if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 { + overrides.PollInterval = d + } + if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 { + overrides.HeartbeatInterval = d + } + if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 { + overrides.AgentTimeout = d + } + + cfg, err := daemon.LoadConfig(overrides) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags) + d := daemon.New(cfg, logger) + + if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} + +func flagString(cmd *cobra.Command, name string) string { + val, _ := cmd.Flags().GetString(name) + return val +} + diff --git a/server/cmd/multica/cmd_runtime.go b/server/cmd/multica/cmd_runtime.go new file mode 100644 index 00000000..a507579f --- /dev/null +++ b/server/cmd/multica/cmd_runtime.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var runtimeCmd = &cobra.Command{ + Use: "runtime", + Short: "Manage agent runtimes", +} + +var runtimeListCmd = &cobra.Command{ + Use: "list", + Short: "List agent runtimes", + RunE: runRuntimeList, +} + +func init() { + runtimeCmd.AddCommand(runtimeListCmd) + + runtimeListCmd.Flags().String("output", "table", "Output format: table or json") +} + +func runRuntimeList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var runtimes []map[string]any + if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil { + return fmt.Errorf("list runtimes: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, runtimes) + } + + headers := []string{"ID", "NAME", "PROVIDER", "STATUS", "DEVICE"} + rows := make([][]string, 0, len(runtimes)) + for _, r := range runtimes { + rows = append(rows, []string{ + strVal(r, "id"), + strVal(r, "name"), + strVal(r, "provider"), + strVal(r, "status"), + strVal(r, "device_info"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} diff --git a/server/cmd/multica/cmd_status.go b/server/cmd/multica/cmd_status.go new file mode 100644 index 00000000..5f9bb8ab --- /dev/null +++ b/server/cmd/multica/cmd_status.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Check server health", + RunE: runStatus, +} + +func runStatus(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + body, err := client.HealthCheck(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Server unreachable: %v\n", err) + return err + } + + fmt.Fprintf(os.Stdout, "Server: %s\n", client.BaseURL) + fmt.Fprintf(os.Stdout, "Status: %s\n", body) + return nil +} diff --git a/server/cmd/multica/cmd_version.go b/server/cmd/multica/cmd_version.go new file mode 100644 index 00000000..99c7fb7a --- /dev/null +++ b/server/cmd/multica/cmd_version.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(_ *cobra.Command, _ []string) { + fmt.Printf("multica-cli %s (commit: %s)\n", version, commit) + }, +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go new file mode 100644 index 00000000..1b06c9e8 --- /dev/null +++ b/server/cmd/multica/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +var ( + version = "dev" + commit = "unknown" +) + +var rootCmd = &cobra.Command{ + Use: "multica-cli", + Short: "Multica CLI — local agent runtime and management tool", + Long: "multica-cli manages local agent runtimes and provides control commands for the Multica platform.", + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)") + rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)") + + rootCmd.AddCommand(daemonCmd) + rootCmd.AddCommand(agentCmd) + rootCmd.AddCommand(runtimeCmd) + rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(versionCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/server/go.mod b/server/go.mod index f1f1f05f..2e2aaa76 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,16 +5,18 @@ go 1.26.1 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.8.0 + github.com/spf13/cobra v1.10.2 ) require ( - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.49.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index a1fa3d9a..40d399bf 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,4 +1,7 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -7,6 +10,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -15,15 +20,24 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go new file mode 100644 index 00000000..90badbb6 --- /dev/null +++ b/server/internal/cli/client.go @@ -0,0 +1,96 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// APIClient is a REST client for the Multica server API. +// Used by ctrl subcommands (agent, runtime, status, etc.). +type APIClient struct { + BaseURL string + WorkspaceID string + HTTPClient *http.Client +} + +// NewAPIClient creates a new API client for ctrl commands. +func NewAPIClient(baseURL, workspaceID string) *APIClient { + return &APIClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + WorkspaceID: workspaceID, + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +// GetJSON performs a GET request and decodes the JSON response. +func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return err + } + if c.WorkspaceID != "" { + req.Header.Set("X-Workspace-ID", c.WorkspaceID) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// DeleteJSON performs a DELETE request. +func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil) + if err != nil { + return err + } + if c.WorkspaceID != "" { + req.Header.Set("X-Workspace-ID", c.WorkspaceID) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("DELETE %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) + } + return nil +} + +// HealthCheck hits the /health endpoint and returns the response body. +func (c *APIClient) HealthCheck(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil) + if err != nil { + return "", err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 400 { + return "", fmt.Errorf("health check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + return strings.TrimSpace(string(data)), nil +} diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go new file mode 100644 index 00000000..a2aa1bb8 --- /dev/null +++ b/server/internal/cli/config.go @@ -0,0 +1,65 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const defaultCLIConfigPath = ".multica/config.json" + +// CLIConfig holds persistent CLI settings. +type CLIConfig struct { + ServerURL string `json:"server_url,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` +} + +// CLIConfigPath returns the default path for the CLI config file. +func CLIConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve CLI config path: %w", err) + } + return filepath.Join(home, defaultCLIConfigPath), nil +} + +// LoadCLIConfig reads the CLI config from disk. +func LoadCLIConfig() (CLIConfig, error) { + path, err := CLIConfigPath() + if err != nil { + return CLIConfig{}, err + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return CLIConfig{}, nil + } + return CLIConfig{}, fmt.Errorf("read CLI config: %w", err) + } + var cfg CLIConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return CLIConfig{}, fmt.Errorf("parse CLI config: %w", err) + } + return cfg, nil +} + +// SaveCLIConfig writes the CLI config to disk. +func SaveCLIConfig(cfg CLIConfig) error { + path, err := CLIConfigPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create CLI config directory: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode CLI config: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write CLI config: %w", err) + } + return nil +} diff --git a/server/internal/cli/flags.go b/server/internal/cli/flags.go new file mode 100644 index 00000000..c67d92ae --- /dev/null +++ b/server/internal/cli/flags.go @@ -0,0 +1,21 @@ +package cli + +import ( + "os" + "strings" + + "github.com/spf13/cobra" +) + +// FlagOrEnv returns the flag value if set, otherwise the environment variable value, +// otherwise the fallback. +func FlagOrEnv(cmd *cobra.Command, flagName, envKey, fallback string) string { + if cmd.Flags().Changed(flagName) { + val, _ := cmd.Flags().GetString(flagName) + return val + } + if v := strings.TrimSpace(os.Getenv(envKey)); v != "" { + return v + } + return fallback +} diff --git a/server/internal/cli/output.go b/server/internal/cli/output.go new file mode 100644 index 00000000..e403038c --- /dev/null +++ b/server/internal/cli/output.go @@ -0,0 +1,26 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// PrintTable writes a simple table with headers and rows to w. +func PrintTable(w io.Writer, headers []string, rows [][]string) { + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, strings.Join(headers, "\t")) + for _, row := range rows { + fmt.Fprintln(tw, strings.Join(row, "\t")) + } + tw.Flush() +} + +// PrintJSON writes v as indented JSON to w. +func PrintJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go new file mode 100644 index 00000000..8f2c58d9 --- /dev/null +++ b/server/internal/daemon/client.go @@ -0,0 +1,141 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client handles HTTP communication with the Multica server daemon API. +type Client struct { + baseURL string + client *http.Client +} + +// NewClient creates a new daemon API client. +func NewClient(baseURL string) *Client { + return &Client{ + baseURL: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) { + var resp struct { + Task *Task `json:"task"` + } + if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil { + return nil, err + } + return resp.Task, nil +} + +func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) { + var resp PairingSession + if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil { + return PairingSession{}, err + } + return resp, nil +} + +func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) { + var resp PairingSession + if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil { + return PairingSession{}, err + } + return resp, nil +} + +func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) { + var resp PairingSession + if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil { + return PairingSession{}, err + } + return resp, nil +} + +func (c *Client) StartTask(ctx context.Context, taskID string) error { + return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil) +} + +func (c *Client) ReportProgress(ctx context.Context, taskID, summary string, step, total int) error { + return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{ + "summary": summary, + "step": step, + "total": total, + }, nil) +} + +func (c *Client) CompleteTask(ctx context.Context, taskID, output string) error { + return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{ + "output": output, + }, nil) +} + +func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error { + return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{ + "error": errMsg, + }, nil) +} + +func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error { + var body io.Reader + if reqBody != nil { + data, err := json.Marshal(reqBody) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("%s %s returned %d: %s", http.MethodPost, path, resp.StatusCode, strings.TrimSpace(string(data))) + } + if respBody == nil { + io.Copy(io.Discard, resp.Body) + return nil + } + return json.NewDecoder(resp.Body).Decode(respBody) +} + +func (c *Client) getJSON(ctx context.Context, path string, respBody any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("%s %s returned %d: %s", http.MethodGet, path, resp.StatusCode, strings.TrimSpace(string(data))) + } + if respBody == nil { + io.Copy(io.Discard, resp.Body) + return nil + } + return json.NewDecoder(resp.Body).Decode(respBody) +} diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go new file mode 100644 index 00000000..6c474771 --- /dev/null +++ b/server/internal/daemon/config.go @@ -0,0 +1,254 @@ +package daemon + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + DefaultServerURL = "ws://localhost:8080/ws" + DefaultDaemonConfigPath = ".multica/daemon.json" + DefaultPollInterval = 3 * time.Second + DefaultHeartbeatInterval = 15 * time.Second + DefaultAgentTimeout = 20 * time.Minute + DefaultRuntimeName = "Local Agent" +) + +// Config holds all daemon configuration. +type Config struct { + ServerBaseURL string + ConfigPath string + WorkspaceID string + DaemonID string + DeviceName string + RuntimeName string + Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry + ReposRoot string // parent directory containing all repos + 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 + WorkspaceID string + ReposRoot string + ConfigPath string + PollInterval time.Duration + HeartbeatInterval time.Duration + AgentTimeout time.Duration + DaemonID string + DeviceName string + RuntimeName string +} + +// LoadConfig builds the daemon configuration from environment variables, +// persisted config, 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 + } + + // Config path + rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG")) + if overrides.ConfigPath != "" { + rawConfigPath = overrides.ConfigPath + } + configPath, err := resolveDaemonConfigPath(rawConfigPath) + if err != nil { + return Config{}, err + } + + // Load persisted config + persisted, err := LoadPersistedConfig(configPath) + if err != nil { + return Config{}, err + } + + // Workspace ID: override > env > persisted + workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID")) + if workspaceID == "" { + workspaceID = persisted.WorkspaceID + } + if overrides.WorkspaceID != "" { + workspaceID = overrides.WorkspaceID + } + + // 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")), + } + } + if len(agents) == 0 { + return Config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH") + } + + // Host info + host, err := os.Hostname() + if err != nil || strings.TrimSpace(host) == "" { + host = "local-machine" + } + + // Repos root: override > env > cwd + reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT")) + if overrides.ReposRoot != "" { + reposRoot = overrides.ReposRoot + } + if reposRoot == "" { + reposRoot, err = os.Getwd() + if err != nil { + return Config{}, fmt.Errorf("resolve working directory: %w", err) + } + } + reposRoot, err = filepath.Abs(reposRoot) + if err != nil { + return Config{}, fmt.Errorf("resolve absolute repos root: %w", err) + } + + // 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 + } + + // String overrides + daemonID := EnvOrDefault("MULTICA_DAEMON_ID", host) + if overrides.DaemonID != "" { + daemonID = overrides.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 + } + + return Config{ + ServerBaseURL: serverBaseURL, + ConfigPath: configPath, + WorkspaceID: workspaceID, + DaemonID: daemonID, + DeviceName: deviceName, + RuntimeName: runtimeName, + Agents: agents, + ReposRoot: reposRoot, + 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 +} + +func resolveDaemonConfigPath(raw string) (string, error) { + if raw != "" { + return filepath.Abs(raw) + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve daemon config path: %w", err) + } + return filepath.Join(home, DefaultDaemonConfigPath), nil +} + +// LoadPersistedConfig reads the daemon config from disk. +func LoadPersistedConfig(path string) (PersistedConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return PersistedConfig{}, nil + } + return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err) + } + var cfg PersistedConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err) + } + return cfg, nil +} + +// SavePersistedConfig writes the daemon config to disk. +func SavePersistedConfig(path string, cfg PersistedConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create daemon config directory: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode daemon config: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write daemon config: %w", err) + } + return nil +} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go new file mode 100644 index 00000000..401241fc --- /dev/null +++ b/server/internal/daemon/daemon.go @@ -0,0 +1,325 @@ +package daemon + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/multica-ai/multica/server/pkg/agent" +) + +// Daemon is the local agent runtime that polls for and executes tasks. +type Daemon struct { + cfg Config + client *Client + logger *log.Logger +} + +// New creates a new Daemon instance. +func New(cfg Config, logger *log.Logger) *Daemon { + return &Daemon{ + cfg: cfg, + client: NewClient(cfg.ServerBaseURL), + logger: logger, + } +} + +// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks. +func (d *Daemon) Run(ctx context.Context) error { + agentNames := make([]string, 0, len(d.cfg.Agents)) + for name := range d.cfg.Agents { + agentNames = append(agentNames, name) + } + d.logger.Printf("starting daemon agents=%v workspace=%s server=%s repos_root=%s", + agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.ReposRoot) + + if strings.TrimSpace(d.cfg.WorkspaceID) == "" { + workspaceID, err := d.ensurePaired(ctx) + if err != nil { + return err + } + d.cfg.WorkspaceID = workspaceID + d.logger.Printf("pairing completed for workspace=%s", workspaceID) + } + + runtimes, err := d.registerRuntimes(ctx) + if err != nil { + return err + } + runtimeIDs := make([]string, 0, len(runtimes)) + for _, rt := range runtimes { + d.logger.Printf("registered runtime id=%s provider=%s status=%s", rt.ID, rt.Provider, rt.Status) + runtimeIDs = append(runtimeIDs, rt.ID) + } + + go d.heartbeatLoop(ctx, runtimeIDs) + return d.pollLoop(ctx, runtimeIDs) +} + +func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) { + var runtimes []map[string]string + for name, entry := range d.cfg.Agents { + version, err := agent.DetectVersion(ctx, entry.Path) + if err != nil { + d.logger.Printf("skip registering %s: %v", name, err) + continue + } + runtimes = append(runtimes, map[string]string{ + "name": fmt.Sprintf("Local %s", strings.ToUpper(name[:1])+name[1:]), + "type": name, + "version": version, + "status": "online", + }) + } + if len(runtimes) == 0 { + return nil, fmt.Errorf("no agent runtimes could be registered") + } + + req := map[string]any{ + "workspace_id": d.cfg.WorkspaceID, + "daemon_id": d.cfg.DaemonID, + "device_name": d.cfg.DeviceName, + "runtimes": runtimes, + } + + var resp struct { + Runtimes []Runtime `json:"runtimes"` + } + if err := d.client.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil { + return nil, fmt.Errorf("register runtimes: %w", err) + } + if len(resp.Runtimes) == 0 { + return nil, fmt.Errorf("register runtimes: empty response") + } + return resp.Runtimes, nil +} + +func (d *Daemon) ensurePaired(ctx context.Context) (string, error) { + // Use a deterministic agent for the pairing session metadata (prefer codex for backward compat). + var firstName string + var firstEntry AgentEntry + for _, preferred := range []string{"codex", "claude"} { + if entry, ok := d.cfg.Agents[preferred]; ok { + firstName = preferred + firstEntry = entry + break + } + } + version, err := agent.DetectVersion(ctx, firstEntry.Path) + if err != nil { + return "", err + } + + session, err := d.client.CreatePairingSession(ctx, map[string]string{ + "daemon_id": d.cfg.DaemonID, + "device_name": d.cfg.DeviceName, + "runtime_name": d.cfg.RuntimeName, + "runtime_type": firstName, + "runtime_version": version, + }) + if err != nil { + return "", fmt.Errorf("create pairing session: %w", err) + } + if session.LinkURL != nil { + d.logger.Printf("open this link to pair the daemon: %s", *session.LinkURL) + } else { + d.logger.Printf("pairing session created: %s", session.Token) + } + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + current, err := d.client.GetPairingSession(ctx, session.Token) + if err != nil { + return "", fmt.Errorf("poll pairing session: %w", err) + } + + switch current.Status { + case "approved", "claimed": + if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" { + return "", fmt.Errorf("pairing session approved without workspace") + } + if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{ + WorkspaceID: strings.TrimSpace(*current.WorkspaceID), + }); err != nil { + return "", err + } + if current.Status != "claimed" { + if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil { + return "", fmt.Errorf("claim pairing session: %w", err) + } + } + return strings.TrimSpace(*current.WorkspaceID), nil + case "expired": + return "", fmt.Errorf("pairing session expired before approval") + } + + if err := SleepWithContext(ctx, d.cfg.PollInterval); err != nil { + return "", err + } + } +} + +func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { + ticker := time.NewTicker(d.cfg.HeartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + for _, rid := range runtimeIDs { + err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ + "runtime_id": rid, + }, nil) + if err != nil { + d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err) + } + } + } + } +} + +func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { + pollOffset := 0 + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + claimed := false + n := len(runtimeIDs) + for i := 0; i < n; i++ { + rid := runtimeIDs[(pollOffset+i)%n] + task, err := d.client.ClaimTask(ctx, rid) + if err != nil { + d.logger.Printf("claim task failed for runtime %s: %v", rid, err) + continue + } + if task != nil { + d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title) + d.handleTask(ctx, *task) + claimed = true + pollOffset = (pollOffset + i + 1) % n + break + } + } + + if !claimed { + pollOffset = (pollOffset + 1) % n + if err := SleepWithContext(ctx, d.cfg.PollInterval); err != nil { + return err + } + } + } +} + +func (d *Daemon) handleTask(ctx context.Context, task Task) { + provider := task.Context.Runtime.Provider + d.logger.Printf("picked task=%s issue=%s provider=%s title=%q", task.ID, task.IssueID, provider, task.Context.Issue.Title) + + if err := d.client.StartTask(ctx, task.ID); err != nil { + d.logger.Printf("start task %s failed: %v", task.ID, err) + return + } + + _ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2) + + result, err := d.runTask(ctx, task) + if err != nil { + d.logger.Printf("task %s failed: %v", task.ID, err) + if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil { + d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr) + } + return + } + + _ = d.client.ReportProgress(ctx, task.ID, "Finishing task", 2, 2) + + switch result.Status { + case "blocked": + if err := d.client.FailTask(ctx, task.ID, result.Comment); err != nil { + d.logger.Printf("report blocked task %s failed: %v", task.ID, err) + } + default: + if err := d.client.CompleteTask(ctx, task.ID, result.Comment); err != nil { + d.logger.Printf("complete task %s failed: %v", task.ID, err) + } + } +} + +func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { + provider := task.Context.Runtime.Provider + entry, ok := d.cfg.Agents[provider] + if !ok { + return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider) + } + + workdir, err := ResolveTaskWorkdir(d.cfg.ReposRoot, task.Context.Issue.Repository) + if err != nil { + return TaskResult{}, err + } + + prompt := BuildPrompt(task, workdir) + + backend, err := agent.New(provider, agent.Config{ + ExecutablePath: entry.Path, + Logger: d.logger, + }) + if err != nil { + return TaskResult{}, fmt.Errorf("create agent backend: %w", err) + } + + d.logger.Printf( + "starting %s task=%s workdir=%s model=%s timeout=%s", + provider, task.ID, workdir, entry.Model, d.cfg.AgentTimeout, + ) + + session, err := backend.Execute(ctx, prompt, agent.ExecOptions{ + Cwd: workdir, + Model: entry.Model, + Timeout: d.cfg.AgentTimeout, + }) + if err != nil { + return TaskResult{}, err + } + + // Drain message channel (log tool uses, ignore text since Result has output) + go func() { + for msg := range session.Messages { + switch msg.Type { + case agent.MessageToolUse: + d.logger.Printf("[%s] tool-use: %s (call=%s)", provider, msg.Tool, msg.CallID) + case agent.MessageError: + d.logger.Printf("[%s] error: %s", provider, msg.Content) + } + } + }() + + result := <-session.Result + + switch result.Status { + case "completed": + if result.Output == "" { + return TaskResult{}, fmt.Errorf("%s returned empty output", provider) + } + return TaskResult{Status: "completed", Comment: result.Output}, nil + case "timeout": + return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout) + default: + errMsg := result.Error + if errMsg == "" { + errMsg = fmt.Sprintf("%s execution %s", provider, result.Status) + } + return TaskResult{Status: "blocked", Comment: errMsg}, nil + } +} diff --git a/server/cmd/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go similarity index 75% rename from server/cmd/daemon/daemon_test.go rename to server/internal/daemon/daemon_test.go index 42efebc4..bca2b976 100644 --- a/server/cmd/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "os" @@ -10,9 +10,9 @@ import ( func TestNormalizeServerBaseURL(t *testing.T) { t.Parallel() - got, err := normalizeServerBaseURL("ws://localhost:8080/ws") + got, err := NormalizeServerBaseURL("ws://localhost:8080/ws") if err != nil { - t.Fatalf("normalizeServerBaseURL returned error: %v", err) + t.Fatalf("NormalizeServerBaseURL returned error: %v", err) } if got != "http://localhost:8080" { t.Fatalf("expected http://localhost:8080, got %s", got) @@ -28,9 +28,9 @@ func TestResolveTaskWorkdirUsesRepoPathWhenPresent(t *testing.T) { t.Fatalf("mkdir repo: %v", err) } - got, err := resolveTaskWorkdir(root, &daemonRepoRef{Path: "repo"}) + got, err := ResolveTaskWorkdir(root, &RepoRef{Path: "repo"}) if err != nil { - t.Fatalf("resolveTaskWorkdir returned error: %v", err) + t.Fatalf("ResolveTaskWorkdir returned error: %v", err) } if got != repoPath { t.Fatalf("expected %s, got %s", repoPath, got) @@ -40,15 +40,15 @@ func TestResolveTaskWorkdirUsesRepoPathWhenPresent(t *testing.T) { func TestBuildPromptIncludesIssueAndSkills(t *testing.T) { t.Parallel() - prompt := buildPrompt(daemonTask{ - Context: daemonTaskContext{ - Issue: daemonIssueContext{ + prompt := BuildPrompt(Task{ + Context: TaskContext{ + Issue: IssueContext{ Title: "Fix failing test", Description: "Investigate and fix the test failure.", AcceptanceCriteria: []string{"tests pass"}, ContextRefs: []string{"log snippet"}, }, - Agent: daemonAgentContext{ + Agent: AgentContext{ Name: "Local Codex", Skills: "Be concise.", }, diff --git a/server/internal/daemon/helpers.go b/server/internal/daemon/helpers.go new file mode 100644 index 00000000..75bb2c24 --- /dev/null +++ b/server/internal/daemon/helpers.go @@ -0,0 +1,46 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "strings" + "time" +) + +// EnvOrDefault returns the trimmed value of the environment variable key, +// falling back to fallback if empty. +func EnvOrDefault(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +// DurationFromEnv parses a duration from an environment variable, +// returning fallback if the variable is empty. +func DurationFromEnv(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + d, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err) + } + return d, nil +} + +// SleepWithContext blocks for the given duration or until the context is cancelled. +func SleepWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go new file mode 100644 index 00000000..0c9ba155 --- /dev/null +++ b/server/internal/daemon/prompt.go @@ -0,0 +1,93 @@ +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// BuildPrompt constructs the task prompt for an agent CLI. +func BuildPrompt(task Task, workdir string) string { + var b strings.Builder + b.WriteString("You are running as a local coding agent for a Multica workspace.\n") + b.WriteString("Complete the assigned issue using the local environment.\n") + b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n") + b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n") + + fmt.Fprintf(&b, "Working directory: %s\n", workdir) + fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name) + fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title) + + if task.Context.Issue.Description != "" { + b.WriteString("Issue description:\n") + b.WriteString(task.Context.Issue.Description) + b.WriteString("\n\n") + } + + if len(task.Context.Issue.AcceptanceCriteria) > 0 { + b.WriteString("Acceptance criteria:\n") + for _, item := range task.Context.Issue.AcceptanceCriteria { + fmt.Fprintf(&b, "- %s\n", item) + } + b.WriteString("\n") + } + + if len(task.Context.Issue.ContextRefs) > 0 { + b.WriteString("Context refs:\n") + for _, item := range task.Context.Issue.ContextRefs { + fmt.Fprintf(&b, "- %s\n", item) + } + b.WriteString("\n") + } + + if repo := task.Context.Issue.Repository; repo != nil { + b.WriteString("Repository context:\n") + if repo.URL != "" { + fmt.Fprintf(&b, "- url: %s\n", repo.URL) + } + if repo.Branch != "" { + fmt.Fprintf(&b, "- branch: %s\n", repo.Branch) + } + if repo.Path != "" { + fmt.Fprintf(&b, "- path: %s\n", repo.Path) + } + b.WriteString("\n") + } + + if task.Context.Agent.Skills != "" { + b.WriteString("Agent skills/instructions:\n") + b.WriteString(task.Context.Agent.Skills) + b.WriteString("\n\n") + } + + b.WriteString("Comment requirements:\n") + b.WriteString("- Lead with the outcome.\n") + b.WriteString("- Mention concrete files or commands if you changed anything.\n") + b.WriteString("- Mention blockers or follow-up actions if relevant.\n") + + return b.String() +} + +// ResolveTaskWorkdir determines the working directory for a task. +func ResolveTaskWorkdir(reposRoot string, repo *RepoRef) (string, error) { + base := reposRoot + if repo == nil || strings.TrimSpace(repo.Path) == "" { + return base, nil + } + + path := strings.TrimSpace(repo.Path) + if !filepath.IsAbs(path) { + path = filepath.Join(base, path) + } + path = filepath.Clean(path) + + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("repository path not found: %s", path) + } + if !info.IsDir() { + return "", fmt.Errorf("repository path is not a directory: %s", path) + } + return path, nil +} diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go new file mode 100644 index 00000000..785edef1 --- /dev/null +++ b/server/internal/daemon/types.go @@ -0,0 +1,89 @@ +package daemon + +// AgentEntry describes a single available agent CLI. +type AgentEntry struct { + Path string // path to CLI binary + Model string // model override (optional) +} + +// Runtime represents a registered daemon runtime. +type Runtime struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Status string `json:"status"` +} + +// PairingSession represents a daemon pairing session. +type PairingSession struct { + Token string `json:"token"` + DaemonID string `json:"daemon_id"` + DeviceName string `json:"device_name"` + RuntimeName string `json:"runtime_name"` + RuntimeType string `json:"runtime_type"` + RuntimeVersion string `json:"runtime_version"` + WorkspaceID *string `json:"workspace_id"` + Status string `json:"status"` + ApprovedAt *string `json:"approved_at"` + ClaimedAt *string `json:"claimed_at"` + ExpiresAt string `json:"expires_at"` + LinkURL *string `json:"link_url"` +} + +// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json. +type PersistedConfig struct { + WorkspaceID string `json:"workspace_id"` +} + +// Task represents a claimed task from the server. +type Task struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + IssueID string `json:"issue_id"` + Context TaskContext `json:"context"` +} + +// TaskContext contains the snapshot context for a task. +type TaskContext struct { + Issue IssueContext `json:"issue"` + Agent AgentContext `json:"agent"` + Runtime RuntimeContext `json:"runtime"` +} + +// IssueContext holds issue details for task execution. +type IssueContext struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + AcceptanceCriteria []string `json:"acceptance_criteria"` + ContextRefs []string `json:"context_refs"` + Repository *RepoRef `json:"repository"` +} + +// AgentContext holds agent details for task execution. +type AgentContext struct { + ID string `json:"id"` + Name string `json:"name"` + Skills string `json:"skills"` +} + +// RuntimeContext holds runtime details for task execution. +type RuntimeContext struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + DeviceInfo string `json:"device_info"` +} + +// RepoRef points to a repository for an issue. +type RepoRef struct { + URL string `json:"url"` + Branch string `json:"branch"` + Path string `json:"path"` +} + +// TaskResult is the outcome of executing a task. +type TaskResult struct { + Status string `json:"status"` + Comment string `json:"comment"` +} From 3293607bef3bfa30aaccf84c484d758e91fc019f Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 15:49:32 +0800 Subject: [PATCH 10/16] fix(cli): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add Client.SendHeartbeat/Register methods — no more direct postJSON calls 2. Use url.Values for query params to prevent URL injection 3. Unexport helpers (envOrDefault, durationFromEnv, sleepWithContext) 4. CLI resolveWorkspaceID falls back to daemon.json 5. Implement agent stop (PUT /api/agents/{id} with status=offline) 6. Add --output flag to agent get for consistent UX 7. Add server/multica to .gitignore for stray builds 8. Inject version/commit via -ldflags in Makefile build target Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + Makefile | 5 +++- server/cmd/multica/cmd_agent.go | 41 ++++++++++++++++++++++++++++--- server/internal/cli/client.go | 33 +++++++++++++++++++++++++ server/internal/cli/config.go | 20 +++++++++++++++ server/internal/daemon/client.go | 16 ++++++++++++ server/internal/daemon/config.go | 18 +++++++------- server/internal/daemon/daemon.go | 19 ++++++-------- server/internal/daemon/helpers.go | 11 +++------ 9 files changed, 130 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index d902de1c..15638010 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ server/tmp/ server/migrate server/daemon server/multica-cli +server/multica # Test artifacts test-results/ diff --git a/Makefile b/Makefile index d5c0b55b..929ba7c7 100644 --- a/Makefile +++ b/Makefile @@ -118,9 +118,12 @@ daemon: cli: cd server && go run ./cmd/multica $(ARGS) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) + build: cd server && go build -o bin/server ./cmd/server - cd server && go build -o bin/multica-cli ./cmd/multica + cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica-cli ./cmd/multica test: cd server && go test ./... diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 69fcaf60..670c8c11 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/url" "os" "time" @@ -50,6 +51,7 @@ func init() { agentCmd.AddCommand(agentStopCmd) agentListCmd.Flags().String("output", "table", "Output format: table or json") + agentGetCmd.Flags().String("output", "json", "Output format: table or json") } func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { @@ -84,7 +86,11 @@ func resolveWorkspaceID(cmd *cobra.Command) string { return val } cfg, _ := cli.LoadCLIConfig() - return cfg.WorkspaceID + if cfg.WorkspaceID != "" { + return cfg.WorkspaceID + } + // Fallback: try daemon.json for workspace_id + return cli.LoadWorkspaceIDFromDaemonConfig() } func runAgentList(cmd *cobra.Command, _ []string) error { @@ -99,7 +105,7 @@ func runAgentList(cmd *cobra.Command, _ []string) error { var agents []map[string]any path := "/api/agents" if client.WorkspaceID != "" { - path += "?workspace_id=" + client.WorkspaceID + path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode() } if err := client.GetJSON(ctx, path, &agents); err != nil { return fmt.Errorf("list agents: %w", err) @@ -138,6 +144,20 @@ func runAgentGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("get agent: %w", err) } + output, _ := cmd.Flags().GetString("output") + if output == "table" { + headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "DESCRIPTION"} + rows := [][]string{{ + strVal(agent, "id"), + strVal(agent, "name"), + strVal(agent, "status"), + strVal(agent, "runtime_mode"), + strVal(agent, "description"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + return cli.PrintJSON(os.Stdout, agent) } @@ -159,8 +179,21 @@ func runAgentDelete(cmd *cobra.Command, args []string) error { } func runAgentStop(cmd *cobra.Command, args []string) error { - // TODO: implement agent stop (PUT /api/agents/{id} with status=offline) - return fmt.Errorf("agent stop is not yet implemented") + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{"status": "offline"} + if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, nil); err != nil { + return fmt.Errorf("stop agent: %w", err) + } + + fmt.Fprintf(os.Stderr, "Agent %s stopped.\n", args[0]) + return nil } func strVal(m map[string]any, key string) string { diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 90badbb6..550f16cb 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "encoding/json" "fmt" @@ -76,6 +77,38 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { return nil } +// PutJSON performs a PUT request with a JSON body. +func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if c.WorkspaceID != "" { + req.Header.Set("X-Workspace-ID", c.WorkspaceID) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("PUT %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData))) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + // HealthCheck hits the /health endpoint and returns the response body. func (c *APIClient) HealthCheck(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil) diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index a2aa1bb8..bb4921dc 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -45,6 +45,26 @@ func LoadCLIConfig() (CLIConfig, error) { return cfg, nil } +// LoadWorkspaceIDFromDaemonConfig reads workspace_id from ~/.multica/daemon.json +// as a fallback when it's not set in the CLI config. +func LoadWorkspaceIDFromDaemonConfig() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, ".multica/daemon.json")) + if err != nil { + return "" + } + var cfg struct { + WorkspaceID string `json:"workspace_id"` + } + if json.Unmarshal(data, &cfg) != nil { + return "" + } + return cfg.WorkspaceID +} + // SaveCLIConfig writes the CLI config to disk. func SaveCLIConfig(cfg CLIConfig) error { path, err := CLIConfigPath() diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index 8f2c58d9..98a654fb 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -84,6 +84,22 @@ func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error { }, nil) } +func (c *Client) SendHeartbeat(ctx context.Context, runtimeID string) error { + return c.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ + "runtime_id": runtimeID, + }, nil) +} + +func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) { + var resp struct { + Runtimes []Runtime `json:"runtimes"` + } + if err := c.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil { + return nil, err + } + return resp.Runtimes, nil +} + func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error { var body io.Reader if reqBody != nil { diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 6c474771..3bf588ce 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -55,7 +55,7 @@ type Overrides struct { // persisted config, and optional CLI flag overrides. func LoadConfig(overrides Overrides) (Config, error) { // Server URL: override > env > default - rawServerURL := EnvOrDefault("MULTICA_SERVER_URL", DefaultServerURL) + rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL) if overrides.ServerURL != "" { rawServerURL = overrides.ServerURL } @@ -91,14 +91,14 @@ func LoadConfig(overrides Overrides) (Config, error) { // Probe available agent CLIs agents := map[string]AgentEntry{} - claudePath := EnvOrDefault("MULTICA_CLAUDE_PATH", "claude") + 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") + codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex") if _, err := exec.LookPath(codexPath); err == nil { agents["codex"] = AgentEntry{ Path: codexPath, @@ -132,7 +132,7 @@ func LoadConfig(overrides Overrides) (Config, error) { } // Durations: override > env > default - pollInterval, err := DurationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval) + pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval) if err != nil { return Config{}, err } @@ -140,7 +140,7 @@ func LoadConfig(overrides Overrides) (Config, error) { pollInterval = overrides.PollInterval } - heartbeatInterval, err := DurationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", DefaultHeartbeatInterval) + heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", DefaultHeartbeatInterval) if err != nil { return Config{}, err } @@ -148,7 +148,7 @@ func LoadConfig(overrides Overrides) (Config, error) { heartbeatInterval = overrides.HeartbeatInterval } - agentTimeout, err := DurationFromEnv("MULTICA_AGENT_TIMEOUT", DefaultAgentTimeout) + agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", DefaultAgentTimeout) if err != nil { return Config{}, err } @@ -157,17 +157,17 @@ func LoadConfig(overrides Overrides) (Config, error) { } // String overrides - daemonID := EnvOrDefault("MULTICA_DAEMON_ID", host) + daemonID := envOrDefault("MULTICA_DAEMON_ID", host) if overrides.DaemonID != "" { daemonID = overrides.DaemonID } - deviceName := EnvOrDefault("MULTICA_DAEMON_DEVICE_NAME", host) + deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host) if overrides.DeviceName != "" { deviceName = overrides.DeviceName } - runtimeName := EnvOrDefault("MULTICA_AGENT_RUNTIME_NAME", DefaultRuntimeName) + runtimeName := envOrDefault("MULTICA_AGENT_RUNTIME_NAME", DefaultRuntimeName) if overrides.RuntimeName != "" { runtimeName = overrides.RuntimeName } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 401241fc..8a4f9cdb 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -84,16 +84,14 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) { "runtimes": runtimes, } - var resp struct { - Runtimes []Runtime `json:"runtimes"` - } - if err := d.client.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil { + rts, err := d.client.Register(ctx, req) + if err != nil { return nil, fmt.Errorf("register runtimes: %w", err) } - if len(resp.Runtimes) == 0 { + if len(rts) == 0 { return nil, fmt.Errorf("register runtimes: empty response") } - return resp.Runtimes, nil + return rts, nil } func (d *Daemon) ensurePaired(ctx context.Context) (string, error) { @@ -160,7 +158,7 @@ func (d *Daemon) ensurePaired(ctx context.Context) (string, error) { return "", fmt.Errorf("pairing session expired before approval") } - if err := SleepWithContext(ctx, d.cfg.PollInterval); err != nil { + if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { return "", err } } @@ -176,10 +174,7 @@ func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { return case <-ticker.C: for _, rid := range runtimeIDs { - err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ - "runtime_id": rid, - }, nil) - if err != nil { + if err := d.client.SendHeartbeat(ctx, rid); err != nil { d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err) } } @@ -216,7 +211,7 @@ func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error { if !claimed { pollOffset = (pollOffset + 1) % n - if err := SleepWithContext(ctx, d.cfg.PollInterval); err != nil { + if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { return err } } diff --git a/server/internal/daemon/helpers.go b/server/internal/daemon/helpers.go index 75bb2c24..a7de9b9e 100644 --- a/server/internal/daemon/helpers.go +++ b/server/internal/daemon/helpers.go @@ -8,9 +8,7 @@ import ( "time" ) -// EnvOrDefault returns the trimmed value of the environment variable key, -// falling back to fallback if empty. -func EnvOrDefault(key, fallback string) string { +func envOrDefault(key, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback @@ -18,9 +16,7 @@ func EnvOrDefault(key, fallback string) string { return value } -// DurationFromEnv parses a duration from an environment variable, -// returning fallback if the variable is empty. -func DurationFromEnv(key string, fallback time.Duration) (time.Duration, error) { +func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback, nil @@ -32,8 +28,7 @@ func DurationFromEnv(key string, fallback time.Duration) (time.Duration, error) return d, nil } -// SleepWithContext blocks for the given duration or until the context is cancelled. -func SleepWithContext(ctx context.Context, d time.Duration) error { +func sleepWithContext(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() From 680668ffdb38fc908f7e4a359b2e4f49d56ca1bf Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 24 Mar 2026 15:59:11 +0800 Subject: [PATCH 11/16] feat(workspace): add context field for AI agent background info Add a `context` text field to workspaces, allowing users to provide background information and context for AI agents working in the workspace. Full stack: migration, sqlc queries, Go handler, TS types, SDK, and settings page UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/settings/page.tsx | 16 ++++++++++ apps/web/lib/auth-context.test.tsx | 4 +++ apps/web/test/helpers.tsx | 1 + packages/sdk/src/api-client.ts | 4 +-- packages/types/src/workspace.ts | 1 + server/internal/handler/workspace.go | 20 ++++++++---- .../migrations/006_workspace_context.down.sql | 1 + .../migrations/006_workspace_context.up.sql | 1 + server/pkg/db/generated/models.go | 19 +++++++++++ server/pkg/db/generated/workspace.sql.go | 32 +++++++++++++------ server/pkg/db/queries/workspace.sql | 5 +-- 11 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 server/migrations/006_workspace_context.down.sql create mode 100644 server/migrations/006_workspace_context.up.sql diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index af2a7ea1..ca31a753 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -95,6 +95,7 @@ export default function SettingsPage() { const [description, setDescription] = useState( workspace?.description ?? "", ); + const [context, setContext] = useState(workspace?.context ?? ""); const [profileName, setProfileName] = useState(user?.name ?? ""); const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [saving, setSaving] = useState(false); @@ -115,6 +116,7 @@ export default function SettingsPage() { useEffect(() => { setName(workspace?.name ?? ""); setDescription(workspace?.description ?? ""); + setContext(workspace?.context ?? ""); }, [workspace]); useEffect(() => { @@ -130,6 +132,7 @@ export default function SettingsPage() { const updated = await api.updateWorkspace(workspace.id, { name, description: description || undefined, + context: context || undefined, }); updateWorkspace(updated); setSaved(true); @@ -330,6 +333,19 @@ export default function SettingsPage() { placeholder="What does this workspace focus on?" /> +
+ +