// Package process manages the agent subprocess lifecycle package process import ( "bufio" "context" "fmt" "io" "os/exec" "strings" "sync" "sync/atomic" "time" "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/shell" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" ) // Status represents the agent process status type Status string const ( StatusStopped Status = "stopped" StatusStarting Status = "starting" StatusRunning Status = "running" StatusPaused Status = "paused" StatusStopping Status = "stopping" StatusError Status = "error" ) // errorWrapper wraps an error so it can be stored in atomic.Value (which cannot store nil) type errorWrapper struct { err error } // PendingPermission represents a permission request waiting for user response type PendingPermission struct { ID string Request *adapter.PermissionRequest ResponseCh chan *adapter.PermissionResponse CreatedAt time.Time } // PermissionNotification is sent when the agent requests permission type PermissionNotification struct { PendingID string `json:"pending_id"` SessionID string `json:"session_id"` ToolCallID string `json:"tool_call_id"` Title string `json:"title"` Options []adapter.PermissionOption `json:"options"` ActionType string `json:"action_type,omitempty"` ActionDetails map[string]interface{} `json:"action_details,omitempty"` CreatedAt time.Time `json:"created_at"` } // Manager manages the agent subprocess type Manager struct { cfg *config.InstanceConfig logger *logger.Logger // Process state cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser status atomic.Value // Status exitCode atomic.Int32 exitErr atomic.Value // error // Workspace tracker for git status and file changes workspaceTracker *WorkspaceTracker // Embedded shell session (auto-created when agent starts) shell *shell.Session // Protocol adapter for agent communication adapter adapter.AgentAdapter // Agent event notifications (protocol-agnostic) updatesCh chan adapter.AgentEvent // Pending permission requests waiting for user response pendingPermissions map[string]*PendingPermission permissionMu sync.RWMutex // Git operator for git operations (lazy-initialized) gitOperator *GitOperator gitOperatorMu sync.Mutex // Synchronization mu sync.RWMutex wg sync.WaitGroup stopCh chan struct{} doneCh chan struct{} startMu sync.Mutex } // NewManager creates a new process manager func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { m := &Manager{ cfg: cfg, logger: log.WithFields(zap.String("component", "process-manager")), workspaceTracker: NewWorkspaceTracker(cfg.WorkDir, log), updatesCh: make(chan adapter.AgentEvent, 100), pendingPermissions: make(map[string]*PendingPermission), } m.status.Store(StatusStopped) m.exitCode.Store(-1) return m } // Status returns the current process status func (m *Manager) Status() Status { return m.status.Load().(Status) } // ExitCode returns the exit code (-1 if not exited) func (m *Manager) ExitCode() int { return int(m.exitCode.Load()) } // ExitError returns the exit error if any func (m *Manager) ExitError() error { if v := m.exitErr.Load(); v != nil { if w, ok := v.(errorWrapper); ok { return w.err } } return nil } // GetWorkspaceTracker returns the workspace tracker for git status and file monitoring func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker { return m.workspaceTracker } // GitOperator returns the git operator for git operations. // The operator is lazy-initialized on first call. func (m *Manager) GitOperator() *GitOperator { m.gitOperatorMu.Lock() defer m.gitOperatorMu.Unlock() if m.gitOperator == nil { m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger) } return m.gitOperator } // Start starts the agent process func (m *Manager) Start(ctx context.Context) error { m.startMu.Lock() defer m.startMu.Unlock() if m.Status() == StatusRunning || m.Status() == StatusStarting { return fmt.Errorf("agent is already running") } m.logger.Info("starting agent process", zap.String("protocol", string(m.cfg.Protocol)), zap.Strings("args", m.cfg.AgentArgs), zap.String("workdir", m.cfg.WorkDir)) m.status.Store(StatusStarting) m.exitCode.Store(-1) m.exitErr.Store(errorWrapper{err: nil}) // Create command if len(m.cfg.AgentArgs) == 0 { m.status.Store(StatusError) return fmt.Errorf("no agent command configured") } // NOTE: We intentionally don't use exec.CommandContext here because we don't want // the HTTP request context to kill the agent process when the request completes. m.cmd = exec.Command(m.cfg.AgentArgs[0], m.cfg.AgentArgs[1:]...) m.cmd.Dir = m.cfg.WorkDir m.cmd.Env = m.cfg.AgentEnv // Set up pipes var err error m.stdin, err = m.cmd.StdinPipe() if err != nil { m.status.Store(StatusError) return fmt.Errorf("failed to create stdin pipe: %w", err) } m.stdout, err = m.cmd.StdoutPipe() if err != nil { m.status.Store(StatusError) return fmt.Errorf("failed to create stdout pipe: %w", err) } m.stderr, err = m.cmd.StderrPipe() if err != nil { m.status.Store(StatusError) return fmt.Errorf("failed to create stderr pipe: %w", err) } // Start the process m.logger.Info("starting agent process", zap.Strings("args", m.cfg.AgentArgs), zap.String("workdir", m.cfg.WorkDir), zap.Int("env_count", len(m.cfg.AgentEnv))) if err := m.cmd.Start(); err != nil { m.status.Store(StatusError) return fmt.Errorf("failed to start agent: %w", err) } m.stopCh = make(chan struct{}) m.doneCh = make(chan struct{}) // Create protocol adapter based on configuration if err := m.createAdapter(); err != nil { m.status.Store(StatusError) return fmt.Errorf("failed to create adapter: %w", err) } // Start stderr reader and exit waiter m.wg.Add(2) go m.readStderr() go m.waitForExit() // Forward adapter updates to our channel m.wg.Add(1) go m.forwardUpdates() // Start workspace tracker with background context (not tied to HTTP request) m.workspaceTracker.Start(context.Background()) // Auto-create shell session if enabled if m.cfg.ShellEnabled { shellCfg := shell.DefaultConfig(m.cfg.WorkDir) shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv) shellSession, err := shell.NewSession(shellCfg, m.logger) if err != nil { m.logger.Warn("failed to create shell session", zap.Error(err)) // Non-fatal: agent can still work without shell } else { m.shell = shellSession m.logger.Info("shell session auto-created") } } m.status.Store(StatusRunning) m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid)) return nil } func preferredShellCommand(env []string) string { if value := lookupEnvValue(env, "AGENTCTL_SHELL_COMMAND"); value != "" { return value } return lookupEnvValue(env, "SHELL") } func lookupEnvValue(env []string, key string) string { prefix := key + "=" for _, entry := range env { if strings.HasPrefix(entry, prefix) { return strings.TrimPrefix(entry, prefix) } } return "" } // Configure sets the agent command and optional environment variables. // This must be called before Start() if the instance was created without a command. func (m *Manager) Configure(command string, env map[string]string) error { m.startMu.Lock() defer m.startMu.Unlock() if m.Status() == StatusRunning || m.Status() == StatusStarting { return fmt.Errorf("cannot configure while agent is running") } if command == "" { return fmt.Errorf("agent command cannot be empty") } // Parse the command string and update config args := config.ParseCommand(command) if len(args) == 0 { return fmt.Errorf("failed to parse agent command") } m.cfg.AgentCommand = command m.cfg.AgentArgs = args // Merge additional env vars if len(env) > 0 { for k, v := range env { m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v)) } } m.logger.Info("agent configured", zap.String("command", command), zap.Strings("args", args), zap.Int("env_count", len(env))) return nil } // createAdapter creates the appropriate protocol adapter based on configuration func (m *Manager) createAdapter() error { protocol := m.cfg.Protocol if protocol == "" { protocol = agent.ProtocolACP // Default to ACP } adapterCfg := &adapter.Config{ WorkDir: m.cfg.WorkDir, AutoApprove: m.cfg.AutoApprovePermissions, ApprovalPolicy: m.cfg.ApprovalPolicy, } switch protocol { case agent.ProtocolACP: m.adapter = adapter.NewACPAdapter(m.stdin, m.stdout, adapterCfg, m.logger) case agent.ProtocolCodex: m.adapter = adapter.NewCodexAdapter(m.stdin, m.stdout, adapterCfg, m.logger) default: return fmt.Errorf("unsupported protocol: %s", protocol) } // Set the permission handler m.adapter.SetPermissionHandler(m.handlePermissionRequest) return nil } // forwardUpdates forwards updates from the adapter to the manager's channel func (m *Manager) forwardUpdates() { defer m.wg.Done() updatesCh := m.adapter.Updates() for { select { case update, ok := <-updatesCh: if !ok { return } select { case m.updatesCh <- update: default: m.logger.Warn("updates channel full, dropping notification") } case <-m.stopCh: return } } } // GetUpdates returns the channel for agent event notifications func (m *Manager) GetUpdates() <-chan adapter.AgentEvent { return m.updatesCh } // GetAdapter returns the protocol adapter func (m *Manager) GetAdapter() adapter.AgentAdapter { m.mu.RLock() defer m.mu.RUnlock() return m.adapter } // GetSessionID returns the current session ID from the adapter. // The adapter is the single source of truth for session ID. func (m *Manager) GetSessionID() string { m.mu.RLock() a := m.adapter m.mu.RUnlock() if a != nil { return a.GetSessionID() } return "" } // Stop stops the agent process func (m *Manager) Stop(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() status := m.Status() if status == StatusStopped || status == StatusStopping { return nil } m.logger.Info("stopping agent process") m.status.Store(StatusStopping) // Stop shell session if m.shell != nil { if err := m.shell.Stop(); err != nil { m.logger.Debug("failed to stop shell session", zap.Error(err)) } m.shell = nil } // Stop workspace tracker if m.workspaceTracker != nil { m.workspaceTracker.Stop() } // Close the adapter if m.adapter != nil { if err := m.adapter.Close(); err != nil { m.logger.Debug("failed to close adapter", zap.Error(err)) } } // Close stop channel to signal readers if m.stopCh != nil { close(m.stopCh) } // Close stdin to signal EOF to agent if m.stdin != nil { if err := m.stdin.Close(); err != nil { m.logger.Debug("failed to close stdin", zap.Error(err)) } } // Wait for process to exit with timeout done := make(chan struct{}) go func() { m.wg.Wait() close(done) }() select { case <-done: m.logger.Info("agent process stopped gracefully") case <-ctx.Done(): // Force kill if m.cmd != nil && m.cmd.Process != nil { m.logger.Warn("force killing agent process") if err := m.cmd.Process.Kill(); err != nil { m.logger.Warn("failed to kill agent process", zap.Error(err)) } } } m.status.Store(StatusStopped) return nil } // readStderr reads and logs stderr from the agent func (m *Manager) readStderr() { defer m.wg.Done() scanner := bufio.NewScanner(m.stderr) for scanner.Scan() { line := scanner.Text() m.logger.Debug("agent stderr", zap.String("line", line)) } if err := scanner.Err(); err != nil { m.logger.Debug("stderr reader error", zap.Error(err)) } } // waitForExit waits for the process to exit func (m *Manager) waitForExit() { defer m.wg.Done() defer close(m.doneCh) err := m.cmd.Wait() if err != nil { m.exitErr.Store(errorWrapper{err: err}) if exitErr, ok := err.(*exec.ExitError); ok { m.exitCode.Store(int32(exitErr.ExitCode())) } m.logger.Info("agent process exited with error", zap.Error(err)) } else { m.exitCode.Store(0) m.logger.Info("agent process exited successfully") } m.status.Store(StatusStopped) } // GetProcessInfo returns information about the process func (m *Manager) GetProcessInfo() map[string]interface{} { info := map[string]interface{}{ "status": string(m.Status()), "exit_code": m.ExitCode(), } if m.cmd != nil && m.cmd.Process != nil { info["pid"] = m.cmd.Process.Pid } if err := m.ExitError(); err != nil { info["exit_error"] = err.Error() } return info } // handlePermissionRequest handles permission requests from the agent // It stores the pending request and waits for a response from the backend func (m *Manager) handlePermissionRequest(ctx context.Context, req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) { // Generate a unique ID for this permission request pendingID := fmt.Sprintf("%s-%s-%d", req.SessionID, req.ToolCallID, time.Now().UnixNano()) m.logger.Info("handling permission request", zap.String("pending_id", pendingID), zap.String("session_id", req.SessionID), zap.String("tool_call_id", req.ToolCallID), zap.String("title", req.Title), zap.Bool("auto_approve", m.cfg.AutoApprovePermissions)) // If auto-approve is enabled, immediately approve with the first "allow" option if m.cfg.AutoApprovePermissions { return m.autoApprovePermission(req) } // Create pending permission with response channel pending := &PendingPermission{ ID: pendingID, Request: req, ResponseCh: make(chan *adapter.PermissionResponse, 1), CreatedAt: time.Now(), } // Store pending permission m.permissionMu.Lock() m.pendingPermissions[pendingID] = pending m.permissionMu.Unlock() // Clean up when done defer func() { m.permissionMu.Lock() delete(m.pendingPermissions, pendingID) m.permissionMu.Unlock() }() // Send notification to backend via updates channel // We use a custom notification type that the backend will recognize m.sendPermissionNotification(pending) // Wait for response indefinitely - user may close and reopen the page select { case resp := <-pending.ResponseCh: m.logger.Info("received permission response", zap.String("pending_id", pendingID), zap.String("option_id", resp.OptionID), zap.Bool("cancelled", resp.Cancelled)) return resp, nil case <-ctx.Done(): m.logger.Warn("permission request context cancelled", zap.String("pending_id", pendingID)) return &adapter.PermissionResponse{Cancelled: true}, nil } } // autoApprovePermission automatically approves a permission request // by selecting the first "allow" option, or the first option if no allow option exists func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) { if len(req.Options) == 0 { m.logger.Warn("no options available for auto-approve, cancelling") return &adapter.PermissionResponse{Cancelled: true}, nil } // Find the first "allow" option var selectedOption *adapter.PermissionOption for i := range req.Options { opt := &req.Options[i] if opt.Kind == "allow_once" || opt.Kind == "allow_always" { selectedOption = opt break } } // If no allow option, use the first option if selectedOption == nil { selectedOption = &req.Options[0] } m.logger.Info("auto-approving permission request", zap.String("option_id", selectedOption.OptionID), zap.String("option_name", selectedOption.Name), zap.String("kind", selectedOption.Kind)) return &adapter.PermissionResponse{ OptionID: selectedOption.OptionID, }, nil } // sendPermissionNotification sends a permission request notification through the updates channel func (m *Manager) sendPermissionNotification(pending *PendingPermission) { // Convert options to streams.PermissionOption (types.PermissionOption is an alias) options := make([]streams.PermissionOption, len(pending.Request.Options)) for i, opt := range pending.Request.Options { options[i] = streams.PermissionOption(opt) } event := adapter.AgentEvent{ Type: adapter.EventTypePermissionRequest, SessionID: pending.Request.SessionID, ToolCallID: pending.Request.ToolCallID, PendingID: pending.ID, PermissionTitle: pending.Request.Title, PermissionOptions: options, ActionType: pending.Request.ActionType, ActionDetails: pending.Request.ActionDetails, } m.logger.Info("sending permission notification via updates channel", zap.String("pending_id", pending.ID), zap.String("title", pending.Request.Title), zap.String("action_type", pending.Request.ActionType)) select { case m.updatesCh <- event: // Sent successfully default: m.logger.Warn("updates channel full, dropping permission notification", zap.String("pending_id", pending.ID)) } } // RespondToPermission responds to a pending permission request func (m *Manager) RespondToPermission(pendingID string, optionID string, cancelled bool) error { m.permissionMu.RLock() pending, ok := m.pendingPermissions[pendingID] m.permissionMu.RUnlock() if !ok { return fmt.Errorf("pending permission not found: %s", pendingID) } m.logger.Info("responding to permission request", zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) // Send response (non-blocking since channel is buffered) select { case pending.ResponseCh <- &adapter.PermissionResponse{ OptionID: optionID, Cancelled: cancelled, }: return nil default: return fmt.Errorf("response channel full for pending permission: %s", pendingID) } } // Shell returns the embedded shell session, or nil if not available func (m *Manager) Shell() *shell.Session { m.mu.RLock() defer m.mu.RUnlock() return m.shell }