package process import ( "bufio" "context" "cursor-api-proxy/internal/env" "fmt" "os/exec" "strings" "sync" "syscall" "time" ) type RunResult struct { Code int Stdout string Stderr string } type RunOptions struct { Cwd string TimeoutMs int MaxMode bool ConfigDir string Ctx context.Context } type RunStreamingOptions struct { RunOptions OnLine func(line string) } // ─── Global child process registry ────────────────────────────────────────── var ( activeMu sync.Mutex activeChildren []*exec.Cmd ) func registerChild(c *exec.Cmd) { activeMu.Lock() activeChildren = append(activeChildren, c) activeMu.Unlock() } func unregisterChild(c *exec.Cmd) { activeMu.Lock() for i, ch := range activeChildren { if ch == c { activeChildren = append(activeChildren[:i], activeChildren[i+1:]...) break } } activeMu.Unlock() } func KillAllChildProcesses() { activeMu.Lock() all := make([]*exec.Cmd, len(activeChildren)) copy(all, activeChildren) activeChildren = nil activeMu.Unlock() for _, c := range all { killProcessGroup(c) } } // ─── Spawn ──────────────────────────────────────────────────────────────── func spawnChild(cmdStr string, args []string, opts *RunOptions, maxModeFn func(scriptPath, configDir string)) *exec.Cmd { envSrc := env.OsEnvToMap() resolved := env.ResolveAgentCommand(cmdStr, args, envSrc, opts.Cwd) if opts.MaxMode && maxModeFn != nil { maxModeFn(resolved.AgentScriptPath, opts.ConfigDir) } envMap := make(map[string]string, len(resolved.Env)) for k, v := range resolved.Env { envMap[k] = v } if opts.ConfigDir != "" { envMap["CURSOR_CONFIG_DIR"] = opts.ConfigDir } else if resolved.ConfigDir != "" { if _, exists := envMap["CURSOR_CONFIG_DIR"]; !exists { envMap["CURSOR_CONFIG_DIR"] = resolved.ConfigDir } } envSlice := make([]string, 0, len(envMap)) for k, v := range envMap { envSlice = append(envSlice, k+"="+v) } ctx := opts.Ctx if ctx == nil { ctx = context.Background() } // 使用 WaitDelay 確保 context cancel 後子程序 goroutine 能及時退出 c := exec.CommandContext(ctx, resolved.Command, resolved.Args...) c.Dir = opts.Cwd c.Env = envSlice // 設定新的 process group,使 kill 能傳遞給所有子孫程序 c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // WaitDelay:context cancel 後額外等待這麼久再強制關閉 pipes c.WaitDelay = 5 * time.Second // Cancel 函式:殺死整個 process group c.Cancel = func() error { return killProcessGroup(c) } return c } // MaxModeFn is set by the agent package to avoid import cycle. var MaxModeFn func(agentScriptPath, configDir string) func Run(cmdStr string, args []string, opts RunOptions) (RunResult, error) { ctx := opts.Ctx var cancel context.CancelFunc if opts.TimeoutMs > 0 { if ctx == nil { ctx, cancel = context.WithTimeout(context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond) } else { ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond) } defer cancel() opts.Ctx = ctx } else if ctx == nil { opts.Ctx = context.Background() } c := spawnChild(cmdStr, args, &opts, MaxModeFn) var stdoutBuf, stderrBuf strings.Builder c.Stdout = &stdoutBuf c.Stderr = &stderrBuf if err := c.Start(); err != nil { // context 已取消或命令找不到時 if opts.Ctx != nil && opts.Ctx.Err() != nil { return RunResult{Code: -1}, nil } if strings.Contains(err.Error(), "exec: ") || strings.Contains(err.Error(), "no such file") { return RunResult{}, fmt.Errorf("command not found: %s. Install Cursor CLI (agent) or set CURSOR_AGENT_BIN to its path", cmdStr) } return RunResult{}, err } registerChild(c) defer unregisterChild(c) err := c.Wait() code := 0 if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { code = exitErr.ExitCode() if code == 0 { code = -1 } } else { // context cancelled or killed — return -1 but no error return RunResult{Code: -1, Stdout: stdoutBuf.String(), Stderr: stderrBuf.String()}, nil } } return RunResult{ Code: code, Stdout: stdoutBuf.String(), Stderr: stderrBuf.String(), }, nil } type StreamResult struct { Code int Stderr string } func RunStreaming(cmdStr string, args []string, opts RunStreamingOptions) (StreamResult, error) { ctx := opts.Ctx var cancel context.CancelFunc if opts.TimeoutMs > 0 { if ctx == nil { ctx, cancel = context.WithTimeout(context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond) } else { ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond) } defer cancel() opts.RunOptions.Ctx = ctx } else if opts.RunOptions.Ctx == nil { opts.RunOptions.Ctx = context.Background() } c := spawnChild(cmdStr, args, &opts.RunOptions, MaxModeFn) stdoutPipe, err := c.StdoutPipe() if err != nil { return StreamResult{}, err } stderrPipe, err := c.StderrPipe() if err != nil { return StreamResult{}, err } if err := c.Start(); err != nil { if strings.Contains(err.Error(), "exec: ") || strings.Contains(err.Error(), "no such file") { return StreamResult{}, fmt.Errorf("command not found: %s. Install Cursor CLI (agent) or set CURSOR_AGENT_BIN to its path", cmdStr) } return StreamResult{}, err } registerChild(c) defer unregisterChild(c) var stderrBuf strings.Builder var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() scanner := bufio.NewScanner(stdoutPipe) scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024) for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) != "" { opts.OnLine(line) } } }() wg.Add(1) go func() { defer wg.Done() scanner := bufio.NewScanner(stderrPipe) scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024) for scanner.Scan() { stderrBuf.WriteString(scanner.Text()) stderrBuf.WriteString("\n") } }() wg.Wait() err = c.Wait() code := 0 if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { code = exitErr.ExitCode() if code == 0 { code = -1 } } } return StreamResult{Code: code, Stderr: stderrBuf.String()}, nil }