diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 0f33252241..9b836b1659 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -206,6 +206,7 @@ func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID st AutoApprove: info.AutoApprove, DangerouslySkipPermissions: info.DangerouslySkipPermissions, Plan: info.Plan, + CLIPassthrough: info.CLIPassthrough, }, nil } diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index 7e0676f068..03577afdf4 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/agent/registry" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" @@ -43,8 +44,13 @@ func provideLifecycleManager( cfg.Agent.StandalonePort, log, ) + + // Create InteractiveRunner for passthrough mode (no WorkspaceTracker, uses callbacks) + interactiveRunner := process.NewInteractiveRunner(nil, log, 2*1024*1024) // 2MB buffer + standaloneRuntime.SetInteractiveRunner(interactiveRunner) + runtimeRegistry.Register(standaloneRuntime) - log.Info("Standalone runtime registered", + log.Info("Standalone runtime registered with passthrough support", zap.String("host", cfg.Agent.StandaloneHost), zap.Int("port", cfg.Agent.StandalonePort)) diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index a39e20526c..9016f48d5c 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -42,6 +42,11 @@ func provideGateway( } _ = cleanup + // Enable dedicated terminal WebSocket for passthrough mode + if lifecycleMgr != nil { + gateway.SetLifecycleManager(lifecycleMgr) + } + orchestratorCtrl := orchestratorcontroller.NewController(orchestratorSvc) orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorCtrl, log) orchestratorHandlers.RegisterHandlers(gateway.Dispatcher) @@ -59,6 +64,9 @@ func provideGateway( gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, log) gitHandlers.RegisterHandlers(gateway.Dispatcher) + + passthroughHandlers := agenthandlers.NewPassthroughHandlers(lifecycleMgr, log) + passthroughHandlers.RegisterHandlers(gateway.Dispatcher) } go gateway.Hub.Run(ctx) diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 85131ef771..d76a5e5e2e 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.11 require ( github.com/coder/acp-go-sdk v0.6.3 - github.com/creack/pty v1.1.18 + github.com/creack/pty v1.1.21 github.com/docker/docker v28.5.2+incompatible github.com/fsnotify/fsnotify v1.6.0 github.com/gin-gonic/gin v1.9.1 @@ -15,8 +15,10 @@ require ( github.com/mark3labs/mcp-go v0.43.2 github.com/mattn/go-sqlite3 v1.14.33 github.com/nats-io/nats.go v1.31.0 + github.com/pelletier/go-toml/v2 v2.0.8 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.11.1 + github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 go.uber.org/zap v1.26.0 ) @@ -63,7 +65,6 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/afero v1.9.5 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 48d862e2d3..a58b499610 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -72,8 +72,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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= @@ -291,6 +291,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 h1:U7j2kc5ZS6dmwTg0H2VbRNGUwUxxEv9HtyxsyiYyS84= +github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7/go.mod h1:kypKQ2Vd/oUOxC2dX6DFMG6FktzHsrYs+BF35RTA7p8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= diff --git a/apps/backend/internal/agent/handlers/passthrough_handlers.go b/apps/backend/internal/agent/handlers/passthrough_handlers.go new file mode 100644 index 0000000000..be3c32508e --- /dev/null +++ b/apps/backend/internal/agent/handlers/passthrough_handlers.go @@ -0,0 +1,123 @@ +// Package handlers provides WebSocket and HTTP handlers for agent operations. +package handlers + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// PassthroughHandlers provides WebSocket handlers for agent CLI passthrough operations. +// These handlers route stdin to the agent process in passthrough mode. +type PassthroughHandlers struct { + lifecycleMgr *lifecycle.Manager + logger *logger.Logger +} + +// NewPassthroughHandlers creates a new PassthroughHandlers instance +func NewPassthroughHandlers(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *PassthroughHandlers { + return &PassthroughHandlers{ + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "passthrough_handlers")), + } +} + +// RegisterHandlers registers passthrough handlers with the WebSocket dispatcher +func (h *PassthroughHandlers) RegisterHandlers(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionAgentStdin, h.wsAgentStdin) + d.RegisterFunc(ws.ActionAgentResize, h.wsAgentResize) +} + +// AgentStdinRequest for agent.stdin action +type AgentStdinRequest struct { + SessionID string `json:"session_id"` + Data string `json:"data"` +} + +// wsAgentStdin sends input to the agent process stdin (passthrough mode) +func (h *PassthroughHandlers) wsAgentStdin(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req AgentStdinRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + + // Get the agent execution for this session + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if !ok { + return nil, fmt.Errorf("no agent running for session %s", req.SessionID) + } + + // Check if this session is running in passthrough mode + passthroughProcessID := execution.PassthroughProcessID + if passthroughProcessID == "" { + return nil, fmt.Errorf("session %s is not in passthrough mode", req.SessionID) + } + + // Write to the interactive runner's stdin + if err := h.lifecycleMgr.WritePassthroughStdin(ctx, req.SessionID, req.Data); err != nil { + h.logger.Error("failed to write to passthrough stdin", + zap.String("session_id", req.SessionID), + zap.Error(err)) + return nil, fmt.Errorf("failed to send agent input: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + }) +} + +// AgentResizeRequest for agent.resize action +type AgentResizeRequest struct { + SessionID string `json:"session_id"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +// wsAgentResize resizes the PTY for an agent in passthrough mode +func (h *PassthroughHandlers) wsAgentResize(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req AgentResizeRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + + if req.Cols == 0 || req.Rows == 0 { + return nil, fmt.Errorf("cols and rows must be non-zero") + } + + // Get the agent execution for this session + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if !ok { + return nil, fmt.Errorf("no agent running for session %s", req.SessionID) + } + + // Check if this session is running in passthrough mode + if execution.PassthroughProcessID == "" { + return nil, fmt.Errorf("session %s is not in passthrough mode", req.SessionID) + } + + // Resize the PTY + if err := h.lifecycleMgr.ResizePassthroughPTY(ctx, req.SessionID, req.Cols, req.Rows); err != nil { + h.logger.Error("failed to resize passthrough PTY", + zap.String("session_id", req.SessionID), + zap.Uint16("cols", req.Cols), + zap.Uint16("rows", req.Rows), + zap.Error(err)) + return nil, fmt.Errorf("failed to resize terminal: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + }) +} diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 93ea7ecaa1..37cef9de77 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/appctx" @@ -407,6 +408,28 @@ func (m *Manager) Start(ctx context.Context) error { m.logger.Info("cleanup loop started") } + // Set up callbacks for passthrough mode (using standalone runtime) + if standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone); err == nil { + if interactiveRunner := standaloneRT.GetInteractiveRunner(); interactiveRunner != nil { + // Turn complete callback + interactiveRunner.SetTurnCompleteCallback(func(sessionID string) { + m.handlePassthroughTurnComplete(sessionID) + }) + + // Output callback for standalone passthrough (no WorkspaceTracker) + interactiveRunner.SetOutputCallback(func(output *agentctltypes.ProcessOutput) { + m.handlePassthroughOutput(output) + }) + + // Status callback for standalone passthrough (no WorkspaceTracker) + interactiveRunner.SetStatusCallback(func(status *agentctltypes.ProcessStatusUpdate) { + m.handlePassthroughStatus(status) + }) + + m.logger.Info("passthrough callbacks configured") + } + } + return nil } @@ -692,6 +715,14 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err return fmt.Errorf("execution %q not found", executionID) } + // Check if this execution should use passthrough mode + if execution.AgentProfileID != "" && m.profileResolver != nil { + profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) + if err == nil && profileInfo.CLIPassthrough { + return m.startPassthroughSession(ctx, execution, profileInfo) + } + } + if execution.agentctl == nil { return fmt.Errorf("execution %q has no agentctl client", executionID) } @@ -1456,6 +1487,22 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) zap.String("runtime", execution.RuntimeName), zap.Error(err)) } else { + // Stop passthrough process if in passthrough mode + if execution.PassthroughProcessID != "" { + if interactiveRunner := rt.GetInteractiveRunner(); interactiveRunner != nil { + if err := interactiveRunner.Stop(ctx, execution.PassthroughProcessID); err != nil { + m.logger.Warn("failed to stop passthrough process", + zap.String("execution_id", executionID), + zap.String("process_id", execution.PassthroughProcessID), + zap.Error(err)) + } else { + m.logger.Info("passthrough process stopped", + zap.String("execution_id", executionID), + zap.String("process_id", execution.PassthroughProcessID)) + } + } + } + runtimeInstance := &RuntimeInstance{ InstanceID: execution.ID, TaskID: execution.TaskID, @@ -1881,3 +1928,360 @@ func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) } + +// WritePassthroughStdin writes data to the agent process stdin in passthrough mode. +// Returns an error if the session is not in passthrough mode or if writing fails. +func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } + + return interactiveRunner.WriteStdin(execution.PassthroughProcessID, data) +} + +// ResizePassthroughPTY resizes the PTY for a passthrough process. +// Returns an error if the session is not in passthrough mode or if resizing fails. +func (m *Manager) ResizePassthroughPTY(ctx context.Context, sessionID string, cols, rows uint16) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } + + return interactiveRunner.ResizeBySession(sessionID, cols, rows) +} + +// GetPassthroughBuffer returns the buffered output from the passthrough process. +// This is used for new subscribers to catch up on output. +func (m *Manager) GetPassthroughBuffer(ctx context.Context, sessionID string) (string, error) { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return "", fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return "", fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return "", fmt.Errorf("interactive runner not available") + } + + chunks, ok := interactiveRunner.GetBuffer(execution.PassthroughProcessID) + if !ok { + return "", fmt.Errorf("passthrough process not found") + } + + // Concatenate all chunks into a single string + var buffer strings.Builder + for _, chunk := range chunks { + buffer.WriteString(chunk.Data) + } + + return buffer.String(), nil +} + +// startPassthroughSession starts an agent in passthrough mode (direct terminal interaction). +// Instead of using ACP protocol, the agent's stdin/stdout is passed through directly. +func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentExecution, profileInfo *AgentProfileInfo) error { + // Get agent config for passthrough command + agentConfig, err := m.getAgentConfigForExecution(execution) + if err != nil { + return fmt.Errorf("failed to get agent config: %w", err) + } + + // Validate passthrough support + if !agentConfig.PassthroughConfig.Supported { + return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID) + } + + // Get task description from metadata for initial prompt + taskDescription := "" + if execution.Metadata != nil { + if desc, ok := execution.Metadata["task_description"].(string); ok { + taskDescription = desc + } + } + + // Build passthrough command with initial prompt and profile settings + cmd := m.buildPassthroughCommand(agentConfig, execution.ACPSessionID, taskDescription, profileInfo) + if len(cmd) == 0 { + return fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID) + } + + m.logger.Info("passthrough command built", + zap.String("session_id", execution.SessionID), + zap.Strings("full_command", cmd)) + + // Get the interactive runner from runtime + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available for passthrough mode") + } + + // Build environment variables + env := make(map[string]string) + env["KANDEV_TASK_ID"] = execution.TaskID + env["KANDEV_SESSION_ID"] = execution.SessionID + env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID + + // Add required credentials from agent config + if m.credsMgr != nil { + for _, credKey := range agentConfig.RequiredEnv { + if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { + env[credKey] = value + } + } + } + + // Start the interactive process immediately with default dimensions + // This allows the agent to start working before the user opens the terminal + startReq := process.InteractiveStartRequest{ + SessionID: execution.SessionID, + Command: cmd, + WorkingDir: execution.WorkspacePath, + Env: env, + PromptPattern: agentConfig.PassthroughConfig.PromptPattern, + IdleTimeoutMs: agentConfig.PassthroughConfig.IdleTimeoutMs, + BufferMaxBytes: agentConfig.PassthroughConfig.BufferMaxBytes, + StatusDetector: agentConfig.PassthroughConfig.StatusDetector, + CheckIntervalMs: agentConfig.PassthroughConfig.CheckIntervalMs, + StabilityWindowMs: agentConfig.PassthroughConfig.StabilityWindowMs, + ImmediateStart: true, // Start immediately, don't wait for terminal resize + DefaultCols: 120, + DefaultRows: 40, + } + + processInfo, err := interactiveRunner.Start(ctx, startReq) + if err != nil { + m.updateExecutionError(execution.ID, "failed to start passthrough session: "+err.Error()) + return fmt.Errorf("failed to start passthrough session: %w", err) + } + + // Store the passthrough process ID + execution.PassthroughProcessID = processInfo.ID + + m.logger.Info("passthrough session started", + zap.String("execution_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", execution.SessionID), + zap.String("process_id", processInfo.ID), + zap.Strings("command", cmd)) + + // Emit agentctl ready event to indicate session is available + m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") + + // Start shell session for workspace shell access. + // In passthrough mode, the agent runs via InteractiveRunner, so the agentctl + // process manager never starts and the shell isn't auto-created. + // We need to start it explicitly so the right panel terminal works. + if execution.agentctl != nil { + if err := execution.agentctl.StartShell(ctx); err != nil { + m.logger.Warn("failed to start shell for passthrough session", + zap.String("execution_id", execution.ID), + zap.Error(err)) + // Non-fatal: continue without shell + } else { + m.logger.Info("shell session started for passthrough mode", + zap.String("execution_id", execution.ID)) + } + } + + // Connect to workspace stream for shell/git/file features even in passthrough mode. + // The passthrough terminal handles agent interaction (center panel), while the + // workspace stream provides shell I/O (right panel) - they work independently. + if m.streamManager != nil && execution.agentctl != nil { + go m.streamManager.connectWorkspaceStream(execution) + } + + return nil +} + +// buildPassthroughCommand builds the command for passthrough mode. +// It uses the passthrough_cmd from config, applies profile settings as CLI flags, +// and appends resume flag if resuming or initial prompt for new sessions. +func (m *Manager) buildPassthroughCommand(agentConfig *registry.AgentTypeConfig, acpSessionID string, initialPrompt string, profileInfo *AgentProfileInfo) []string { + // Start with passthrough_cmd + cmd := make([]string, len(agentConfig.PassthroughConfig.PassthroughCmd)) + copy(cmd, agentConfig.PassthroughConfig.PassthroughCmd) + + // Apply model flag if configured and profile has a model + if profileInfo != nil && profileInfo.Model != "" && agentConfig.PassthroughConfig.ModelFlag != "" { + expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.ModelFlag, "{model}", profileInfo.Model) + // Split on first space to separate flag from value (if combined) + parts := strings.SplitN(expanded, " ", 2) + cmd = append(cmd, parts...) + } + + // Apply permission settings that use CLI flags + // Build a map of permission values from profile info + if profileInfo != nil && agentConfig.PermissionSettings != nil { + permissionValues := map[string]bool{ + "auto_approve": profileInfo.AutoApprove, + "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, + "allow_indexing": profileInfo.AllowIndexing, + } + + for settingName, setting := range agentConfig.PermissionSettings { + // Skip if not supported or not a CLI flag setting + if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { + continue + } + + // Get the value for this setting + value, exists := permissionValues[settingName] + if !exists || !value { + continue + } + + // Apply the CLI flag + if setting.CLIFlagValue != "" { + // Flag with value: "--flag value" + cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) + } else { + // Boolean flag or multiple flags: "--flag" or "--flag1 --flag2 arg" + // Split on spaces to handle multiple flags in one setting + parts := strings.Fields(setting.CLIFlag) + cmd = append(cmd, parts...) + } + } + } + + // Add resume flag if: + // 1. Session ID is provided (resuming existing session) + // 2. Agent uses CLI-based resume (not ACP) + // 3. Agent has a ResumeFlag configured + if acpSessionID != "" && + !agentConfig.SessionConfig.ResumeViaACP && + agentConfig.SessionConfig.ResumeFlag != "" { + cmd = append(cmd, agentConfig.SessionConfig.ResumeFlag, acpSessionID) + } else if initialPrompt != "" { + // For new sessions, add the initial prompt + // Use PromptFlag if configured (e.g., "--prompt {prompt}"), otherwise append directly + if agentConfig.PassthroughConfig.PromptFlag != "" { + expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.PromptFlag, "{prompt}", initialPrompt) + parts := strings.SplitN(expanded, " ", 2) + cmd = append(cmd, parts...) + } else { + // Default: append prompt as a positional argument + cmd = append(cmd, initialPrompt) + } + } + + return cmd +} + +// handlePassthroughTurnComplete is called when turn detection fires for a passthrough session. +// This marks the execution as ready for follow-up prompts when the agent finishes processing. +func (m *Manager) handlePassthroughTurnComplete(sessionID string) { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + m.logger.Debug("turn complete for unknown session (may have ended)", + zap.String("session_id", sessionID)) + return + } + + m.logger.Info("passthrough turn complete", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID)) + + // Mark execution as ready for follow-up prompts + // This publishes AgentReady event to notify subscribers + if err := m.MarkReady(execution.ID); err != nil { + m.logger.Error("failed to mark execution as ready after passthrough turn complete", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } +} + +// handlePassthroughOutput handles output from a passthrough process and publishes it to the event bus. +// This is called when running in standalone mode without a WorkspaceTracker. +func (m *Manager) handlePassthroughOutput(output *agentctltypes.ProcessOutput) { + if output == nil { + return + } + + execution, exists := m.executionStore.GetBySessionID(output.SessionID) + if !exists { + m.logger.Debug("passthrough output for unknown session", + zap.String("session_id", output.SessionID)) + return + } + + // Convert to agentctl client type for event publisher + clientOutput := &agentctl.ProcessOutput{ + SessionID: output.SessionID, + ProcessID: output.ProcessID, + Kind: streams.ProcessKind(output.Kind), + Stream: output.Stream, + Data: output.Data, + Timestamp: output.Timestamp, + } + + m.eventPublisher.PublishProcessOutput(execution, clientOutput) +} + +// handlePassthroughStatus handles status updates from a passthrough process and publishes to the event bus. +// This is called when running in standalone mode without a WorkspaceTracker. +func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpdate) { + if status == nil { + return + } + + execution, exists := m.executionStore.GetBySessionID(status.SessionID) + if !exists { + m.logger.Debug("passthrough status for unknown session", + zap.String("session_id", status.SessionID)) + return + } + + // Convert to agentctl client type for event publisher + clientStatus := &agentctl.ProcessStatusUpdate{ + SessionID: status.SessionID, + ProcessID: status.ProcessID, + Kind: streams.ProcessKind(status.Kind), + Command: status.Command, + ScriptName: status.ScriptName, + WorkingDir: status.WorkingDir, + Status: streams.ProcessStatus(status.Status), + ExitCode: status.ExitCode, + Timestamp: status.Timestamp, + } + + m.eventPublisher.PublishProcessStatus(execution, clientStatus) +} + +// GetInteractiveRunner returns the interactive runner for passthrough mode. +// Returns nil if the runtime is not available or doesn't support passthrough. +func (m *Manager) GetInteractiveRunner() *process.InteractiveRunner { + if m.runtimeRegistry == nil { + return nil + } + standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone) + if err != nil { + return nil + } + return standaloneRT.GetInteractiveRunner() +} diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver.go b/apps/backend/internal/agent/lifecycle/profile_resolver.go index f997be6fe8..730705eb61 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver.go @@ -52,6 +52,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str AutoApprove: profile.AutoApprove, DangerouslySkipPermissions: profile.DangerouslySkipPermissions, AllowIndexing: profile.AllowIndexing, + CLIPassthrough: profile.CLIPassthrough, Plan: profile.Plan, }, nil } diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go index bf14e63176..be93c84923 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -8,6 +8,7 @@ import ( "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -32,6 +33,10 @@ type Runtime interface { // RecoverInstances discovers and recovers instances that were running before a restart. // Returns recovered instances that can be re-tracked by the manager. RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) + + // GetInteractiveRunner returns the interactive runner for passthrough mode. + // May return nil if the runtime doesn't support passthrough mode. + GetInteractiveRunner() *process.InteractiveRunner } // McpServerConfig holds configuration for an MCP server. diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go index fa358cd619..2a6f5cc2b8 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_docker.go +++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) @@ -207,3 +208,9 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc return recovered, nil } + +// GetInteractiveRunner returns nil for Docker runtime. +// Passthrough mode is not supported in Docker-based execution. +func (r *DockerRuntime) GetInteractiveRunner() *process.InteractiveRunner { + return nil +} diff --git a/apps/backend/internal/agent/lifecycle/runtime_registry_test.go b/apps/backend/internal/agent/lifecycle/runtime_registry_test.go index c01c069f37..991446876b 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_registry_test.go +++ b/apps/backend/internal/agent/lifecycle/runtime_registry_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) @@ -31,6 +32,9 @@ func (m *MockRuntime) StopInstance(ctx context.Context, instance *RuntimeInstanc func (m *MockRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { return m.recoverInstances, m.recoverErr } +func (m *MockRuntime) GetInteractiveRunner() *process.InteractiveRunner { + return nil +} func newTestRegistryLogger() *logger.Logger { log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index d4ce60add3..2abc1a7ec0 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -9,16 +9,18 @@ import ( "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) // StandaloneRuntime implements Runtime for standalone agentctl execution. // In this mode, a single agentctl control server manages multiple agent instances. type StandaloneRuntime struct { - ctl *agentctl.ControlClient - host string - port int - logger *logger.Logger + ctl *agentctl.ControlClient + host string + port int + logger *logger.Logger + interactiveRunner *process.InteractiveRunner } // NewStandaloneRuntime creates a new standalone runtime. @@ -159,3 +161,13 @@ func (r *StandaloneRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeIns // managed by agentctl. Session resume will restart them as needed. return nil, nil } + +// SetInteractiveRunner sets the interactive runner for passthrough mode. +func (r *StandaloneRuntime) SetInteractiveRunner(runner *process.InteractiveRunner) { + r.interactiveRunner = runner +} + +// GetInteractiveRunner returns the interactive runner for passthrough mode. +func (r *StandaloneRuntime) GetInteractiveRunner() *process.InteractiveRunner { + return r.interactiveRunner +} diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 7e84abbd0f..2e58221203 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -46,6 +46,9 @@ type AgentExecution struct { standaloneInstanceID string // Instance ID in standalone agentctl standalonePort int // Port of the standalone execution + // Passthrough mode info (CLI passthrough without ACP) + PassthroughProcessID string // Process ID in the interactive runner (empty if not in passthrough mode) + // Buffers for accumulating agent response during a prompt messageBuffer strings.Builder reasoningBuffer strings.Builder @@ -121,6 +124,7 @@ type AgentProfileInfo struct { AutoApprove bool DangerouslySkipPermissions bool AllowIndexing bool + CLIPassthrough bool Plan string } diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index 07c0b569ff..407fcd63c6 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -11,9 +11,25 @@ "image": "kandev/multi-agent", "tag": "latest", "cmd": [ - "auggie", + "npx", + "-y", + "@augmentcode/auggie@0.14.0", "--acp" ], + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "npx", + "-y", + "@augmentcode/auggie" + ], + "model_flag": "--model {model}", + "prompt_pattern": "^>\\s*$", + "idle_timeout_ms": 5000, + "buffer_max_bytes": 2097152 + }, "working_dir": "/workspace", "required_env": [ "AUGMENT_SESSION_AUTH" @@ -157,9 +173,29 @@ "image": "kandev/multi-agent", "tag": "latest", "cmd": [ - "codex", + "npx", + "-y", + "@openai/codex@0.88.0", "app-server" ], + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "npx", + "-y", + "@openai/codex", + "--full-auto" + ], + "model_flag": "--model {model}", + "prompt_pattern": "^>\\s*$", + "idle_timeout_ms": 5000, + "buffer_max_bytes": 2097152, + "status_detector": "codex", + "check_interval_ms": 100, + "stability_window_ms": 1000 + }, "working_dir": "/workspace", "required_env": [], "env": {}, @@ -209,7 +245,7 @@ "label": "Auto-approve", "description": "Skip approval requests from Codex", "apply_method": "acp", - "cli_flag": "" + "cli_flag": "--full-auto" } }, "discovery": { @@ -285,7 +321,7 @@ "cmd": [ "npx", "-y", - "@anthropic-ai/claude-code@2.1.12", + "@anthropic-ai/claude-code@2.1.19", "-p", "--output-format=stream-json", "--input-format=stream-json", @@ -293,6 +329,24 @@ "--disallowedTools=AskUserQuestion", "--verbose" ], + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "npx", + "-y", + "@anthropic-ai/claude-code@2.1.19", + "--verbose" + ], + "model_flag": "--model {model}", + "prompt_pattern": "^>\\s*$", + "idle_timeout_ms": 5000, + "buffer_max_bytes": 2097152, + "status_detector": "claude_code", + "check_interval_ms": 100, + "stability_window_ms": 0 + }, "working_dir": "{workspace}", "required_env": [ "ANTHROPIC_API_KEY" @@ -335,7 +389,7 @@ "permission_settings": { "auto_approve": { "supported": true, - "default": false, + "default": true, "label": "Auto-approve", "description": "Automatically approve tool calls via stdio protocol", "apply_method": "stdio", @@ -417,7 +471,7 @@ "cmd": [ "npx", "-y", - "@google/gemini-cli@0.23.0", + "@google/gemini-cli@0.25.2", "--experimental-acp" ], "working_dir": "{workspace}", @@ -456,6 +510,22 @@ "edit_file" ] }, + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "npx @google/gemini-cli" + ], + "model_flag": "--model {model}", + "prompt_flag": "--prompt-interactive {prompt}", + "prompt_pattern": "^>\\s*$", + "idle_timeout_ms": 5000, + "buffer_max_bytes": 2097152, + "status_detector": "", + "check_interval_ms": 100, + "stability_window_ms": 0 + }, "permission_settings": { "auto_approve": { "supported": true, @@ -534,6 +604,22 @@ "--port", "0" ], + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "opencode" + ], + "model_flag": "--model {model}", + "prompt_flag": "--prompt {prompt}", + "prompt_pattern": "^>\\s*$", + "idle_timeout_ms": 5000, + "buffer_max_bytes": 2097152, + "status_detector": "", + "check_interval_ms": 100, + "stability_window_ms": 0 + }, "working_dir": "{workspace}", "required_env": [], "env": {}, diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 1b8f769b90..486a67f3eb 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -63,6 +63,9 @@ type AgentTypeConfig struct { // Model configuration ModelConfig ModelConfig `json:"model_config,omitempty"` // Available models and default model + // Passthrough configuration (for CLI passthrough mode) + PassthroughConfig PassthroughConfig `json:"passthrough_config,omitempty"` // CLI passthrough mode settings + // Display name for UI (shorter than Name) DisplayName string `json:"display_name,omitempty"` } @@ -200,6 +203,50 @@ type DiscoveryConfig struct { DiscoveryCapabilities DiscoveryCapabilities `json:"discovery_capabilities"` } +// PassthroughConfig defines configuration for CLI passthrough mode +type PassthroughConfig struct { + // Supported indicates whether passthrough mode is available for this agent + Supported bool `json:"supported"` + + // Label is the display label for the UI toggle + Label string `json:"label"` + + // Description explains what passthrough mode does + Description string `json:"description"` + + // PassthroughCmd is the command to run in passthrough mode (without ACP/protocol flags) + PassthroughCmd []string `json:"passthrough_cmd,omitempty"` + + // ModelFlag is the CLI flag template for specifying the model, e.g. "--model {model}" + // The {model} placeholder will be replaced with the model ID from the profile + ModelFlag string `json:"model_flag,omitempty"` + + // PromptFlag is the CLI flag template for specifying the prompt, e.g. "--prompt {prompt}" + // The {prompt} placeholder will be replaced with the actual prompt. + // If not set, the prompt is appended directly at the end of the command. + PromptFlag string `json:"prompt_flag,omitempty"` + + // PromptPattern is the regex pattern to detect agent prompts (for turn detection) + PromptPattern string `json:"prompt_pattern,omitempty"` + + // IdleTimeoutMs is the idle timeout in milliseconds for turn detection + IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` + + // BufferMaxBytes is the max size of the output ring buffer (default 2MB) + BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` + + // StatusDetector specifies which detector to use for TUI state detection + // Valid values: "claude_code", "codex", "" (no detection) + StatusDetector string `json:"status_detector,omitempty"` + + // CheckIntervalMs is how often to check agent state (default 100ms) + CheckIntervalMs int `json:"check_interval_ms,omitempty"` + + // StabilityWindowMs is how long state must be stable before reporting (default 0) + // Codex uses 1000ms to prevent false exits from working state + StabilityWindowMs int `json:"stability_window_ms,omitempty"` +} + // Registry manages agent type configurations type Registry struct { agents map[string]*AgentTypeConfig diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 668f1a01fb..e97559da88 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -29,9 +29,11 @@ var ( const defaultAgentProfileName = "default" type Controller struct { - repo store.Repository - discovery *discovery.Registry - agentRegistry interface{ Get(id string) (*registry.AgentTypeConfig, bool) } + repo store.Repository + discovery *discovery.Registry + agentRegistry interface { + Get(id string) (*registry.AgentTypeConfig, bool) + } sessionChecker SessionChecker mcpService *mcpconfig.Service logger *logger.Logger @@ -41,7 +43,10 @@ type SessionChecker interface { HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) } -func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry interface{ Get(id string) (*registry.AgentTypeConfig, bool) }, sessionChecker SessionChecker, log *logger.Logger) *Controller { +func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry interface { + Get(id string) (*registry.AgentTypeConfig, bool) +}, sessionChecker SessionChecker, log *logger.Logger, +) *Controller { return &Controller{ repo: repo, discovery: discoveryRegistry, @@ -111,29 +116,40 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl // Convert permission settings var permissionSettings map[string]dto.PermissionSettingDTO - if agentConfig, ok := c.agentRegistry.Get(def.Name); ok && agentConfig.PermissionSettings != nil { - permissionSettings = make(map[string]dto.PermissionSettingDTO, len(agentConfig.PermissionSettings)) - for key, setting := range agentConfig.PermissionSettings { - permissionSettings[key] = dto.PermissionSettingDTO{ - Supported: setting.Supported, - Default: setting.Default, - Label: setting.Label, - Description: setting.Description, - ApplyMethod: setting.ApplyMethod, - CLIFlag: setting.CLIFlag, - CLIFlagValue: setting.CLIFlagValue, + var passthroughConfig *dto.PassthroughConfigDTO + if agentConfig, ok := c.agentRegistry.Get(def.Name); ok { + if agentConfig.PermissionSettings != nil { + permissionSettings = make(map[string]dto.PermissionSettingDTO, len(agentConfig.PermissionSettings)) + for key, setting := range agentConfig.PermissionSettings { + permissionSettings[key] = dto.PermissionSettingDTO{ + Supported: setting.Supported, + Default: setting.Default, + Label: setting.Label, + Description: setting.Description, + ApplyMethod: setting.ApplyMethod, + CLIFlag: setting.CLIFlag, + CLIFlagValue: setting.CLIFlagValue, + } + } + } + // Convert passthrough config + if agentConfig.PassthroughConfig.Supported { + passthroughConfig = &dto.PassthroughConfigDTO{ + Supported: agentConfig.PassthroughConfig.Supported, + Label: agentConfig.PassthroughConfig.Label, + Description: agentConfig.PassthroughConfig.Description, } } } payload = append(payload, dto.AvailableAgentDTO{ - Name: availability.Name, - DisplayName: displayName, - SupportsMCP: availability.SupportsMCP, - MCPConfigPath: availability.MCPConfigPath, - InstallationPaths: availability.InstallationPaths, - Available: availability.Available, - MatchedPath: availability.MatchedPath, + Name: availability.Name, + DisplayName: displayName, + SupportsMCP: availability.SupportsMCP, + MCPConfigPath: availability.MCPConfigPath, + InstallationPaths: availability.InstallationPaths, + Available: availability.Available, + MatchedPath: availability.MatchedPath, Capabilities: dto.AgentCapabilitiesDTO{ SupportsSessionResume: def.Capabilities.SupportsSessionResume, SupportsShell: def.Capabilities.SupportsShell, @@ -144,6 +160,7 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl AvailableModels: modelEntries, }, PermissionSettings: permissionSettings, + PassthroughConfig: passthroughConfig, UpdatedAt: now, }) } @@ -440,6 +457,7 @@ type CreateProfileRequest struct { AutoApprove bool DangerouslySkipPermissions bool AllowIndexing bool + CLIPassthrough bool Plan string } @@ -464,6 +482,7 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest AutoApprove: req.AutoApprove, DangerouslySkipPermissions: req.DangerouslySkipPermissions, AllowIndexing: req.AllowIndexing, + CLIPassthrough: req.CLIPassthrough, Plan: req.Plan, } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { @@ -480,6 +499,7 @@ type UpdateProfileRequest struct { AutoApprove *bool DangerouslySkipPermissions *bool AllowIndexing *bool + CLIPassthrough *bool Plan *string } @@ -507,6 +527,9 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest if req.AllowIndexing != nil { profile.AllowIndexing = *req.AllowIndexing } + if req.CLIPassthrough != nil { + profile.CLIPassthrough = *req.CLIPassthrough + } if req.Plan != nil { profile.Plan = *req.Plan } @@ -571,6 +594,7 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { AutoApprove: profile.AutoApprove, DangerouslySkipPermissions: profile.DangerouslySkipPermissions, AllowIndexing: profile.AllowIndexing, + CLIPassthrough: profile.CLIPassthrough, Plan: profile.Plan, CreatedAt: profile.CreatedAt, UpdatedAt: profile.UpdatedAt, @@ -613,3 +637,143 @@ func (c *Controller) defaultModelByAgent() map[string]string { } return mapped } + +// CommandPreviewRequest contains the draft settings for command preview +type CommandPreviewRequest struct { + Model string + PermissionSettings map[string]bool + CLIPassthrough bool +} + +// PreviewAgentCommand generates a preview of the CLI command that will be executed +func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, req CommandPreviewRequest) (*dto.CommandPreviewResponse, error) { + // Get agent config from registry + agentConfig, ok := c.agentRegistry.Get(agentName) + if !ok { + return nil, fmt.Errorf("agent type %q not found in registry", agentName) + } + + // Build the command based on whether passthrough is supported AND enabled + var cmd []string + if agentConfig.PassthroughConfig.Supported && req.CLIPassthrough { + cmd = c.buildPassthroughCommandPreview(agentConfig, req) + } else { + cmd = c.buildStandardCommandPreview(agentConfig, req) + } + + // Build the command string with proper quoting for display + cmdString := c.buildCommandString(cmd) + + return &dto.CommandPreviewResponse{ + Supported: true, + Command: cmd, + CommandString: cmdString, + }, nil +} + +// buildStandardCommandPreview builds the standard command (non-passthrough) for preview purposes +func (c *Controller) buildStandardCommandPreview(agentConfig *registry.AgentTypeConfig, req CommandPreviewRequest) []string { + // Start with base command from config + cmd := make([]string, len(agentConfig.Cmd)) + copy(cmd, agentConfig.Cmd) + + // Apply model flag if configured and model is set + if req.Model != "" && agentConfig.ModelFlag != "" { + expanded := strings.ReplaceAll(agentConfig.ModelFlag, "{model}", req.Model) + parts := strings.SplitN(expanded, " ", 2) + cmd = append(cmd, parts...) + } + + // Apply permission settings that use CLI flags + cmd = c.applyPermissionFlags(cmd, agentConfig, req.PermissionSettings) + + return cmd +} + +// buildPassthroughCommandPreview builds the passthrough command for preview purposes +func (c *Controller) buildPassthroughCommandPreview(agentConfig *registry.AgentTypeConfig, req CommandPreviewRequest) []string { + // Start with passthrough_cmd + cmd := make([]string, len(agentConfig.PassthroughConfig.PassthroughCmd)) + copy(cmd, agentConfig.PassthroughConfig.PassthroughCmd) + + // Apply model flag if configured and model is set + if req.Model != "" && agentConfig.PassthroughConfig.ModelFlag != "" { + expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.ModelFlag, "{model}", req.Model) + // Split on first space to separate flag from value (if combined) + parts := strings.SplitN(expanded, " ", 2) + cmd = append(cmd, parts...) + } + + // Apply permission settings that use CLI flags + cmd = c.applyPermissionFlags(cmd, agentConfig, req.PermissionSettings) + + // Add prompt - use PromptFlag if configured, otherwise append directly + cmd = c.appendPromptPlaceholder(cmd, agentConfig.PassthroughConfig.PromptFlag) + + return cmd +} + +// appendPromptPlaceholder adds the {prompt} placeholder to the command +// If promptFlag is set (e.g., "--prompt {prompt}"), it uses that format +// Otherwise, it appends {prompt} directly at the end +func (c *Controller) appendPromptPlaceholder(cmd []string, promptFlag string) []string { + if promptFlag != "" { + // Use the configured prompt flag format, e.g., "--prompt {prompt}" + parts := strings.SplitN(promptFlag, " ", 2) + return append(cmd, parts...) + } + // Default: append prompt directly at the end + return append(cmd, "{prompt}") +} + +// applyPermissionFlags applies CLI flags for permission settings that are enabled +func (c *Controller) applyPermissionFlags(cmd []string, agentConfig *registry.AgentTypeConfig, permissionValues map[string]bool) []string { + if agentConfig.PermissionSettings == nil || permissionValues == nil { + return cmd + } + + for settingName, setting := range agentConfig.PermissionSettings { + // Skip if not supported or not a CLI flag setting + if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { + continue + } + + // Get the value for this setting from the request + value, exists := permissionValues[settingName] + if !exists || !value { + continue + } + + // Apply the CLI flag + if setting.CLIFlagValue != "" { + // Flag with value: "--flag value" + cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) + } else { + // Boolean flag or multiple flags: "--flag" or "--flag1 --flag2 arg" + // Split on spaces to handle multiple flags in one setting + parts := strings.Fields(setting.CLIFlag) + cmd = append(cmd, parts...) + } + } + + return cmd +} + +// buildCommandString builds a display-friendly command string with proper quoting. +// Note: We intentionally don't use strconv.Quote here because it produces Go string +// syntax (e.g., "\t" becomes "\\t") which is not ideal for shell command display. +// The manual quoting approach produces more readable shell-style output. +func (c *Controller) buildCommandString(cmd []string) string { + var parts []string + for _, arg := range cmd { + // Quote arguments that contain spaces or special characters + if strings.ContainsAny(arg, " \t\n\"'`$\\") { + // Use double quotes and escape internal double quotes + escaped := strings.ReplaceAll(arg, "\"", "\\\"") + parts = append(parts, "\""+escaped+"\"") + } else { + parts = append(parts, arg) + } + } + return strings.Join(parts, " ") +} diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go new file mode 100644 index 0000000000..d9e1a9c2dc --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -0,0 +1,391 @@ +package controller + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/common/logger" +) + +// mockAgentRegistry implements the agent registry interface for testing +type mockAgentRegistry struct { + agents map[string]*registry.AgentTypeConfig +} + +func (m *mockAgentRegistry) Get(id string) (*registry.AgentTypeConfig, bool) { + agent, ok := m.agents[id] + return agent, ok +} + +func newTestController(agents map[string]*registry.AgentTypeConfig) *Controller { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + }) + return &Controller{ + agentRegistry: &mockAgentRegistry{agents: agents}, + logger: log, + } +} + +func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { + agents := map[string]*registry.AgentTypeConfig{ + "test-agent": { + Name: "test-agent", + Cmd: []string{"test-cli", "--verbose"}, + ModelFlag: "--model {model}", + PermissionSettings: map[string]registry.PermissionSetting{ + "auto_approve": { + Supported: true, + ApplyMethod: "cli_flag", + CLIFlag: "--yes", + }, + }, + PassthroughConfig: registry.PassthroughConfig{ + Supported: false, + }, + }, + } + + controller := newTestController(agents) + + req := CommandPreviewRequest{ + Model: "gpt-4", + PermissionSettings: map[string]bool{"auto_approve": true}, + CLIPassthrough: false, + } + + result, err := controller.PreviewAgentCommand(context.Background(), "test-agent", req) + if err != nil { + t.Fatalf("PreviewAgentCommand() error = %v", err) + } + + if !result.Supported { + t.Error("PreviewAgentCommand() Supported = false, want true") + } + + // Verify command contains expected parts + expectedParts := []string{"test-cli", "--verbose", "--model", "gpt-4", "--yes"} + for _, part := range expectedParts { + found := false + for _, cmdPart := range result.Command { + if cmdPart == part { + found = true + break + } + } + if !found { + t.Errorf("PreviewAgentCommand() command missing %q, got %v", part, result.Command) + } + } +} + +func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { + agents := map[string]*registry.AgentTypeConfig{ + "claude-code": { + Name: "claude-code", + Cmd: []string{"claude"}, + ModelFlag: "--model {model}", + PassthroughConfig: registry.PassthroughConfig{ + Supported: true, + PassthroughCmd: []string{"npx", "-y", "@anthropic-ai/claude-code"}, + ModelFlag: "--model {model}", + PromptFlag: "--prompt {prompt}", + }, + PermissionSettings: map[string]registry.PermissionSetting{ + "dangerously_skip_permissions": { + Supported: true, + ApplyMethod: "cli_flag", + CLIFlag: "--dangerously-skip-permissions", + }, + }, + }, + } + + controller := newTestController(agents) + + req := CommandPreviewRequest{ + Model: "claude-sonnet-4-20250514", + PermissionSettings: map[string]bool{"dangerously_skip_permissions": true}, + CLIPassthrough: true, + } + + result, err := controller.PreviewAgentCommand(context.Background(), "claude-code", req) + if err != nil { + t.Fatalf("PreviewAgentCommand() error = %v", err) + } + + // Verify it uses passthrough command + if len(result.Command) < 3 || result.Command[0] != "npx" { + t.Errorf("PreviewAgentCommand() should use passthrough command, got %v", result.Command) + } + + // Verify model flag is present + hasModel := false + for i, part := range result.Command { + if part == "--model" && i+1 < len(result.Command) && result.Command[i+1] == "claude-sonnet-4-20250514" { + hasModel = true + break + } + } + if !hasModel { + t.Errorf("PreviewAgentCommand() missing model flag, got %v", result.Command) + } + + // Verify permission flag is present + hasPermFlag := false + for _, part := range result.Command { + if part == "--dangerously-skip-permissions" { + hasPermFlag = true + break + } + } + if !hasPermFlag { + t.Errorf("PreviewAgentCommand() missing permission flag, got %v", result.Command) + } + + // Verify prompt placeholder is present + hasPrompt := false + for _, part := range result.Command { + if part == "--prompt" || part == "{prompt}" { + hasPrompt = true + break + } + } + if !hasPrompt { + t.Errorf("PreviewAgentCommand() missing prompt placeholder, got %v", result.Command) + } +} + +func TestController_PreviewAgentCommand_AgentNotFound(t *testing.T) { + controller := newTestController(map[string]*registry.AgentTypeConfig{}) + + _, err := controller.PreviewAgentCommand(context.Background(), "nonexistent", CommandPreviewRequest{}) + if err == nil { + t.Error("PreviewAgentCommand() should return error for unknown agent") + } +} + +func TestController_PreviewAgentCommand_PassthroughDisabled(t *testing.T) { + agents := map[string]*registry.AgentTypeConfig{ + "test-agent": { + Name: "test-agent", + Cmd: []string{"test-cli"}, + PassthroughConfig: registry.PassthroughConfig{ + Supported: true, + PassthroughCmd: []string{"passthrough-cli"}, + }, + }, + } + + controller := newTestController(agents) + + // CLIPassthrough is false, so should use standard command + req := CommandPreviewRequest{ + CLIPassthrough: false, + } + + result, err := controller.PreviewAgentCommand(context.Background(), "test-agent", req) + if err != nil { + t.Fatalf("PreviewAgentCommand() error = %v", err) + } + + if result.Command[0] != "test-cli" { + t.Errorf("PreviewAgentCommand() should use standard command when passthrough disabled, got %v", result.Command) + } +} + +func TestController_BuildCommandString(t *testing.T) { + controller := newTestController(nil) + + tests := []struct { + name string + cmd []string + expected string + }{ + { + name: "simple command", + cmd: []string{"echo", "hello"}, + expected: "echo hello", + }, + { + name: "command with spaces", + cmd: []string{"echo", "hello world"}, + expected: `echo "hello world"`, + }, + { + name: "command with quotes", + cmd: []string{"echo", `say "hi"`}, + expected: `echo "say \"hi\""`, + }, + { + name: "command with special chars", + cmd: []string{"bash", "-c", "echo $HOME"}, + expected: `bash -c "echo $HOME"`, + }, + { + name: "empty command", + cmd: []string{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := controller.buildCommandString(tt.cmd) + if result != tt.expected { + t.Errorf("buildCommandString(%v) = %q, want %q", tt.cmd, result, tt.expected) + } + }) + } +} + +func TestController_ApplyPermissionFlags(t *testing.T) { + controller := newTestController(nil) + + agentConfig := ®istry.AgentTypeConfig{ + PermissionSettings: map[string]registry.PermissionSetting{ + "auto_approve": { + Supported: true, + ApplyMethod: "cli_flag", + CLIFlag: "--yes", + }, + "skip_permissions": { + Supported: true, + ApplyMethod: "cli_flag", + CLIFlag: "--skip", + CLIFlagValue: "all", + }, + "unsupported": { + Supported: false, + CLIFlag: "--unsupported", + }, + "env_method": { + Supported: true, + ApplyMethod: "env_var", // Not cli_flag + CLIFlag: "--env", + }, + }, + } + + tests := []struct { + name string + initial []string + permissions map[string]bool + expected []string + }{ + { + name: "boolean flag enabled", + initial: []string{"cmd"}, + permissions: map[string]bool{"auto_approve": true}, + expected: []string{"cmd", "--yes"}, + }, + { + name: "flag with value", + initial: []string{"cmd"}, + permissions: map[string]bool{"skip_permissions": true}, + expected: []string{"cmd", "--skip", "all"}, + }, + { + name: "flag disabled", + initial: []string{"cmd"}, + permissions: map[string]bool{"auto_approve": false}, + expected: []string{"cmd"}, + }, + { + name: "unsupported flag ignored", + initial: []string{"cmd"}, + permissions: map[string]bool{"unsupported": true}, + expected: []string{"cmd"}, + }, + { + name: "non-cli method ignored", + initial: []string{"cmd"}, + permissions: map[string]bool{"env_method": true}, + expected: []string{"cmd"}, + }, + { + name: "multiple flags", + initial: []string{"cmd"}, + permissions: map[string]bool{"auto_approve": true, "skip_permissions": true}, + expected: []string{"cmd", "--yes", "--skip", "all"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := controller.applyPermissionFlags(tt.initial, agentConfig, tt.permissions) + + // Check that all expected parts are present (order may vary for maps) + if len(result) != len(tt.expected) { + t.Errorf("applyPermissionFlags() length = %d, want %d", len(result), len(tt.expected)) + t.Errorf("got: %v, want: %v", result, tt.expected) + } + }) + } +} + +func TestController_AppendPromptPlaceholder(t *testing.T) { + controller := newTestController(nil) + + tests := []struct { + name string + cmd []string + promptFlag string + expected []string + }{ + { + name: "with prompt flag", + cmd: []string{"cli"}, + promptFlag: "--prompt {prompt}", + expected: []string{"cli", "--prompt", "{prompt}"}, + }, + { + name: "without prompt flag", + cmd: []string{"cli"}, + promptFlag: "", + expected: []string{"cli", "{prompt}"}, + }, + { + name: "single flag format", + cmd: []string{"cli"}, + promptFlag: "-p {prompt}", + expected: []string{"cli", "-p", "{prompt}"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := controller.appendPromptPlaceholder(tt.cmd, tt.promptFlag) + if len(result) != len(tt.expected) { + t.Errorf("appendPromptPlaceholder() = %v, want %v", result, tt.expected) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("appendPromptPlaceholder()[%d] = %q, want %q", i, result[i], tt.expected[i]) + } + } + }) + } +} + +func TestCommandPreviewResponse_DTO(t *testing.T) { + resp := dto.CommandPreviewResponse{ + Supported: true, + Command: []string{"npx", "claude-code", "--model", "gpt-4"}, + CommandString: `npx claude-code --model gpt-4`, + } + + if !resp.Supported { + t.Error("CommandPreviewResponse.Supported should be true") + } + if len(resp.Command) != 4 { + t.Errorf("CommandPreviewResponse.Command length = %d, want 4", len(resp.Command)) + } + if resp.CommandString == "" { + t.Error("CommandPreviewResponse.CommandString should not be empty") + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 4f7b2ac953..999c785d7e 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -15,6 +15,7 @@ type AgentProfileDTO struct { AutoApprove bool `json:"auto_approve"` DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` AllowIndexing bool `json:"allow_indexing"` + CLIPassthrough bool `json:"cli_passthrough"` Plan string `json:"plan"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -79,6 +80,12 @@ type PermissionSettingDTO struct { CLIFlagValue string `json:"cli_flag_value,omitempty"` } +type PassthroughConfigDTO struct { + Supported bool `json:"supported"` + Label string `json:"label"` + Description string `json:"description"` +} + type AvailableAgentDTO struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -90,6 +97,7 @@ type AvailableAgentDTO struct { Capabilities AgentCapabilitiesDTO `json:"capabilities"` ModelConfig ModelConfigDTO `json:"model_config"` PermissionSettings map[string]PermissionSettingDTO `json:"permission_settings,omitempty"` + PassthroughConfig *PassthroughConfigDTO `json:"passthrough_config,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -104,3 +112,17 @@ type AgentProfileMcpConfigDTO struct { Servers map[string]mcpconfig.ServerDef `json:"servers"` Meta map[string]any `json:"meta,omitempty"` } + +// CommandPreviewRequest is the request body for previewing the agent CLI command +type CommandPreviewRequest struct { + Model string `json:"model"` + PermissionSettings map[string]bool `json:"permission_settings"` + CLIPassthrough bool `json:"cli_passthrough"` +} + +// CommandPreviewResponse is the response for the command preview endpoint +type CommandPreviewResponse struct { + Supported bool `json:"supported"` + Command []string `json:"command"` + CommandString string `json:"command_string"` +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index bb6498fc41..6c1fb4a0a7 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -46,6 +46,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.PATCH("/agents/:id", h.httpUpdateAgent) api.DELETE("/agents/:id", h.httpDeleteAgent) api.POST("/agents/:id/profiles", h.httpCreateProfile) + api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) api.PATCH("/agent-profiles/:id", h.httpUpdateProfile) api.DELETE("/agent-profiles/:id", h.httpDeleteProfile) api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig) @@ -283,6 +284,7 @@ type createProfileRequest struct { AutoApprove bool `json:"auto_approve"` DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` AllowIndexing bool `json:"allow_indexing"` + CLIPassthrough bool `json:"cli_passthrough"` Plan string `json:"plan"` } @@ -303,6 +305,7 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { AutoApprove: body.AutoApprove, DangerouslySkipPermissions: body.DangerouslySkipPermissions, AllowIndexing: body.AllowIndexing, + CLIPassthrough: body.CLIPassthrough, Plan: body.Plan, }) if err != nil { @@ -325,6 +328,7 @@ type updateProfileRequest struct { AutoApprove *bool `json:"auto_approve,omitempty"` DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"` AllowIndexing *bool `json:"allow_indexing,omitempty"` + CLIPassthrough *bool `json:"cli_passthrough,omitempty"` Plan *string `json:"plan,omitempty"` } @@ -345,6 +349,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { AutoApprove: body.AutoApprove, DangerouslySkipPermissions: body.DangerouslySkipPermissions, AllowIndexing: body.AllowIndexing, + CLIPassthrough: body.CLIPassthrough, Plan: body.Plan, }) if err != nil { @@ -388,3 +393,36 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"success": true}) } + +type commandPreviewRequest struct { + Model string `json:"model"` + PermissionSettings map[string]bool `json:"permission_settings"` + CLIPassthrough bool `json:"cli_passthrough"` +} + +func (h *Handlers) httpPreviewAgentCommand(c *gin.Context) { + agentName := c.Param("agentName") + if agentName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return + } + + var body commandPreviewRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + resp, err := h.controller.PreviewAgentCommand(c.Request.Context(), agentName, controller.CommandPreviewRequest{ + Model: body.Model, + PermissionSettings: body.PermissionSettings, + CLIPassthrough: body.CLIPassthrough, + }) + if err != nil { + h.logger.Error("failed to preview agent command", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 3889bc49bf..373b12740a 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -21,6 +21,7 @@ type AgentProfile struct { AutoApprove bool `json:"auto_approve"` DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` AllowIndexing bool `json:"allow_indexing"` + CLIPassthrough bool `json:"cli_passthrough"` Plan string `json:"plan"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index bb2ef2d50b..12c42520a8 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -88,6 +88,9 @@ func (r *sqliteRepository) initSchema() error { if err := ensureColumn(r.db, "agent_profiles", "agent_display_name", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := ensureColumn(r.db, "agent_profiles", "cli_passthrough", "INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } return nil } @@ -248,10 +251,10 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode profile.CreatedAt = now profile.UpdatedAt = now _, err := r.db.ExecContext(ctx, ` - INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, plan, created_at, updated_at, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, boolToInt(profile.AutoApprove), - boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) + boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), boolToInt(profile.CLIPassthrough), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) return err } @@ -259,10 +262,10 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode profile.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, ` UPDATE agent_profiles - SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, plan = ?, updated_at = ? + SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, plan = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `, profile.Name, profile.AgentDisplayName, profile.Model, boolToInt(profile.AutoApprove), - boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), profile.Plan, profile.UpdatedAt, profile.ID) + boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), boolToInt(profile.CLIPassthrough), profile.Plan, profile.UpdatedAt, profile.ID) if err != nil { return err } @@ -290,7 +293,7 @@ func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) er func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { row := r.db.QueryRowContext(ctx, ` - SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, plan, created_at, updated_at, deleted_at + SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE id = ? AND deleted_at IS NULL `, id) return scanAgentProfile(row) @@ -298,7 +301,7 @@ func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*mod func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, plan, created_at, updated_at, deleted_at + SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `, agentID) if err != nil { @@ -350,6 +353,7 @@ func scanAgentProfile(scanner interface { var autoApprove int var skipPermissions int var allowIndexing int + var cliPassthrough int if err := scanner.Scan( &profile.ID, &profile.AgentID, @@ -359,6 +363,7 @@ func scanAgentProfile(scanner interface { &autoApprove, &skipPermissions, &allowIndexing, + &cliPassthrough, &profile.Plan, &profile.CreatedAt, &profile.UpdatedAt, @@ -369,6 +374,7 @@ func scanAgentProfile(scanner interface { profile.AutoApprove = autoApprove == 1 profile.DangerouslySkipPermissions = skipPermissions == 1 profile.AllowIndexing = allowIndexing == 1 + profile.CLIPassthrough = cliPassthrough == 1 return profile, nil } diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index 84d532534f..ed738e8db4 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -435,6 +435,27 @@ func (c *Client) ShellBuffer(ctx context.Context) (string, error) { return result.Data, nil } +// StartShell starts the shell session without starting the agent process. +// This is used in passthrough mode where the agent runs directly via InteractiveRunner. +func (c *Client) StartShell(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/shell/start", nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("start shell failed: %s", resp.Status) + } + + return nil +} + func (c *Client) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) { body, err := json.Marshal(req) if err != nil { diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 1042c1122c..21ee628bff 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -88,6 +88,7 @@ func (s *Server) setupRoutes() { // Shell access (HTTP endpoints only - streaming is via /workspace/stream) api.GET("/shell/status", s.handleShellStatus) api.GET("/shell/buffer", s.handleShellBuffer) + api.POST("/shell/start", s.handleShellStart) // Process runner api.POST("/processes/start", s.handleStartProcess) @@ -309,3 +310,13 @@ func (s *Server) handleShellBuffer(c *gin.Context) { Data: string(buffered), }) } + +// handleShellStart starts the shell session without starting the agent process. +// This is used in passthrough mode where the agent runs directly via InteractiveRunner. +func (s *Server) handleShellStart(c *gin.Context) { + if err := s.procMgr.StartShell(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "shell started"}) +} diff --git a/apps/backend/internal/agentctl/server/process/claude_code_detector.go b/apps/backend/internal/agentctl/server/process/claude_code_detector.go new file mode 100644 index 0000000000..e9b5cd50cf --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/claude_code_detector.go @@ -0,0 +1,173 @@ +// Package process provides background process execution and output streaming for agentctl. +// +// ClaudeCodeDetector implements agent-specific TUI pattern matching for Claude Code CLI +// to detect states: Working, WaitingApproval, WaitingInput. + +package process + +import ( + "regexp" + "strings" + + "github.com/tuzig/vt10x" +) + +// ClaudeCodeDetector detects Claude Code CLI states by analyzing the terminal TUI. +type ClaudeCodeDetector struct{} + +// NewClaudeCodeDetector creates a new Claude Code detector. +func NewClaudeCodeDetector() *ClaudeCodeDetector { + return &ClaudeCodeDetector{} +} + +var ( + // Working State Detection + // Pattern: Task line with symbol + ellipsis + interrupt hint + // Example: "✻ Billowing… (ctrl+c to interrupt)" + // Example: "✻ Reading files... (esc to interrupt)" + workingTaskPattern = regexp.MustCompile( + `^\s*[✻✽✶∴·○◆▪▫□■☐☑☒★☆✓✔✗✘⚬⚫⚪⬤◯▸▹►▻◂◃◄◅✢*]\s+.+[…\.]{2,}\s*\((esc|ctrl\+c)\s+to\s+interrupt`, + ) + + // Tip/Hint line pattern (indicates waiting for input) + // Example: "⎿ Tip: Press Enter to continue" + tipPattern = regexp.MustCompile(`^[\s\x{00a0}]*⎿[\s\x{00a0}]+(?:Tip|Next|Hint):`) + + // Separator line pattern (input box boundaries) + // A line that's mostly horizontal box-drawing characters + separatorPattern = regexp.MustCompile(`^[─━═┄┅┈┉\-]{10,}$`) + + // Approval patterns + // Pattern: Navigation prompt (menu selection) + enterToSelectPattern = regexp.MustCompile(`(?i)enter\s+to\s+select`) + + // Pattern: Answer submission + submitAnswersPattern = regexp.MustCompile(`(?i)ready\s+to\s+submit\s+your\s+answers`) + + // Pattern: File operations + doYouWantToPattern = regexp.MustCompile(`(?i)do\s+you\s+want\s+to\s+`) + + // Pattern: Procedural confirmation + doYouWantToProceedPattern = regexp.MustCompile(`(?i)do\s+you\s+want\s+to\s+proceed`) + + // Pattern: Selection arrow for menu + selectionArrowPattern = regexp.MustCompile(`^[\s]*[❯>]\s*\d+\.`) + + // Pattern: Yes/No confirmation prompt + yesNoPattern = regexp.MustCompile(`(?i)\[?y/?n\]?`) +) + +// DetectState examines the visible terminal content and returns the detected state. +func (d *ClaudeCodeDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { + // Try approval detection first (highest priority) + if state := d.detectApproval(lines); state != StateUnknown { + return state + } + + // Then working/waiting detection + return d.detectWorkingAndWaiting(lines, glyphs) +} + +// ShouldAcceptStateChange determines if a state transition should be accepted. +// Claude Code doesn't need a stability window. +func (d *ClaudeCodeDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { + return true +} + +// detectWorkingAndWaiting detects working and waiting for input states. +func (d *ClaudeCodeDetector) detectWorkingAndWaiting(lines []string, glyphs [][]vt10x.Glyph) AgentState { + // Find input box boundaries (lines of horizontal separator characters) + separatorIndices := d.findSeparatorLines(lines) + + // Check for working task pattern in the visible lines + for _, line := range lines { + line = strings.TrimRight(line, " \t") + if workingTaskPattern.MatchString(line) { + return StateWorking + } + } + + // If we have an input box, look for tip/hint line (indicates waiting for input) + if len(separatorIndices) >= 2 { + inputBoxStart := separatorIndices[len(separatorIndices)-2] + inputBoxEnd := separatorIndices[len(separatorIndices)-1] + + for i := inputBoxEnd - 1; i >= inputBoxStart; i-- { + if i >= 0 && i < len(lines) { + if tipPattern.MatchString(lines[i]) { + return StateWaitingInput + } + } + } + } + + // Also check for tip pattern anywhere in the visible area + for _, line := range lines { + if tipPattern.MatchString(line) { + return StateWaitingInput + } + } + + return StateUnknown +} + +// detectApproval detects approval prompts. +func (d *ClaudeCodeDetector) detectApproval(lines []string) AgentState { + // Search from bottom up since prompts are typically at the bottom + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimRight(lines[i], " \t") + + // Check for various approval patterns + if enterToSelectPattern.MatchString(line) { + return StateWaitingApproval + } + + if doYouWantToProceedPattern.MatchString(line) { + return StateWaitingApproval + } + + if submitAnswersPattern.MatchString(line) { + return StateWaitingApproval + } + + // "Do you want to [create/delete/etc]" + if doYouWantToPattern.MatchString(line) { + return StateWaitingApproval + } + + // Selection arrow with confirmation context + if selectionArrowPattern.MatchString(line) { + // Check if there's a confirmation prompt nearby + for j := i + 1; j < len(lines) && j < i+5; j++ { + nearbyLine := strings.TrimRight(lines[j], " \t") + if strings.Contains(strings.ToLower(nearbyLine), "confirm") || + strings.Contains(strings.ToLower(nearbyLine), "enter to") { + return StateWaitingApproval + } + } + } + + // Yes/No prompt + if yesNoPattern.MatchString(line) && (strings.Contains(strings.ToLower(line), "?") || + strings.Contains(strings.ToLower(line), "allow") || + strings.Contains(strings.ToLower(line), "approve")) { + return StateWaitingApproval + } + } + + return StateUnknown +} + +// findSeparatorLines finds lines that are horizontal separators (input box boundaries). +func (d *ClaudeCodeDetector) findSeparatorLines(lines []string) []int { + var indices []int + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if len(trimmed) >= 10 && separatorPattern.MatchString(trimmed) { + indices = append(indices, i) + } + } + + return indices +} diff --git a/apps/backend/internal/agentctl/server/process/claude_code_detector_test.go b/apps/backend/internal/agentctl/server/process/claude_code_detector_test.go new file mode 100644 index 0000000000..b4153f86f4 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/claude_code_detector_test.go @@ -0,0 +1,271 @@ +package process + +import ( + "testing" + + "github.com/tuzig/vt10x" +) + +func TestClaudeCodeDetector_DetectState_Working(t *testing.T) { + detector := NewClaudeCodeDetector() + + tests := []struct { + name string + lines []string + expected AgentState + }{ + { + name: "working with ellipsis and esc interrupt", + lines: []string{ + "", + "✻ Billowing... (esc to interrupt)", + "", + }, + expected: StateWorking, + }, + { + name: "working with dots and ctrl+c interrupt", + lines: []string{ + "", + "✻ Reading files... (ctrl+c to interrupt)", + "", + }, + expected: StateWorking, + }, + { + name: "working with different bullet symbol", + lines: []string{ + "* Processing request... (esc to interrupt)", + }, + expected: StateWorking, + }, + { + name: "working with star symbol", + lines: []string{ + " ★ Analyzing code... (esc to interrupt)", + }, + expected: StateWorking, + }, + { + name: "not working - no interrupt hint", + lines: []string{ + "✻ Billowing…", + }, + expected: StateUnknown, + }, + { + name: "not working - incomplete pattern", + lines: []string{ + "Some random text", + }, + expected: StateUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create empty glyphs slice matching lines + glyphs := make([][]vt10x.Glyph, len(tt.lines)) + for i, line := range tt.lines { + glyphs[i] = make([]vt10x.Glyph, len(line)) + } + + result := detector.DetectState(tt.lines, glyphs) + if result != tt.expected { + t.Errorf("DetectState() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestClaudeCodeDetector_DetectState_WaitingInput(t *testing.T) { + detector := NewClaudeCodeDetector() + + tests := []struct { + name string + lines []string + expected AgentState + }{ + { + name: "tip line indicates waiting for input", + lines: []string{ + "Some output", + "⎿ Tip: Press Enter to continue", + "", + }, + expected: StateWaitingInput, + }, + { + name: "tip with non-breaking spaces", + lines: []string{ + "\u00a0\u00a0⎿\u00a0Tip: Use arrow keys", + }, + expected: StateWaitingInput, + }, + { + name: "hint line", + lines: []string{ + "⎿ Hint: Type your message", + }, + expected: StateWaitingInput, + }, + { + name: "next line", + lines: []string{ + "⎿ Next: Choose an option", + }, + expected: StateWaitingInput, + }, + { + name: "input box with tip inside", + lines: []string{ + "────────────────────", + "", + "⎿ Tip: Enter to send", + "────────────────────", + }, + expected: StateWaitingInput, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + glyphs := make([][]vt10x.Glyph, len(tt.lines)) + for i, line := range tt.lines { + glyphs[i] = make([]vt10x.Glyph, len(line)) + } + + result := detector.DetectState(tt.lines, glyphs) + if result != tt.expected { + t.Errorf("DetectState() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestClaudeCodeDetector_DetectState_WaitingApproval(t *testing.T) { + detector := NewClaudeCodeDetector() + + tests := []struct { + name string + lines []string + expected AgentState + }{ + { + name: "enter to select prompt", + lines: []string{ + "Options:", + "1. Option A", + "2. Option B", + "Press enter to select", + }, + expected: StateWaitingApproval, + }, + { + name: "do you want to proceed", + lines: []string{ + "Changes detected", + "Do you want to proceed?", + }, + expected: StateWaitingApproval, + }, + { + name: "do you want to create file", + lines: []string{ + "Do you want to create this file?", + }, + expected: StateWaitingApproval, + }, + { + name: "submit answers prompt", + lines: []string{ + "Ready to submit your answers?", + }, + expected: StateWaitingApproval, + }, + { + name: "yes/no with allow", + lines: []string{ + "Allow access to file? [y/n]", + }, + expected: StateWaitingApproval, + }, + { + name: "yes/no with approve", + lines: []string{ + "Approve changes? [Y/N]", + }, + expected: StateWaitingApproval, + }, + { + name: "selection arrow with confirm nearby", + lines: []string{ + "❯ 1. First option", + " 2. Second option", + "Press Enter to confirm", + }, + expected: StateWaitingApproval, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + glyphs := make([][]vt10x.Glyph, len(tt.lines)) + for i, line := range tt.lines { + glyphs[i] = make([]vt10x.Glyph, len(line)) + } + + result := detector.DetectState(tt.lines, glyphs) + if result != tt.expected { + t.Errorf("DetectState() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestClaudeCodeDetector_ShouldAcceptStateChange(t *testing.T) { + detector := NewClaudeCodeDetector() + + // Claude Code detector always accepts state changes (no stability window) + tests := []struct { + current AgentState + new AgentState + expected bool + }{ + {StateUnknown, StateWorking, true}, + {StateWorking, StateWaitingInput, true}, + {StateWorking, StateUnknown, true}, + {StateWaitingApproval, StateWorking, true}, + } + + for _, tt := range tests { + result := detector.ShouldAcceptStateChange(tt.current, tt.new) + if result != tt.expected { + t.Errorf("ShouldAcceptStateChange(%v, %v) = %v, want %v", + tt.current, tt.new, result, tt.expected) + } + } +} + +func TestClaudeCodeDetector_FindSeparatorLines(t *testing.T) { + detector := NewClaudeCodeDetector() + + lines := []string{ + "Some text", + "──────────────────────────", + "Input area", + "──────────────────────────", + "More text", + } + + indices := detector.findSeparatorLines(lines) + + if len(indices) != 2 { + t.Errorf("findSeparatorLines() found %d separators, want 2", len(indices)) + } + if len(indices) >= 2 { + if indices[0] != 1 || indices[1] != 3 { + t.Errorf("findSeparatorLines() = %v, want [1, 3]", indices) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/codex_detector.go b/apps/backend/internal/agentctl/server/process/codex_detector.go new file mode 100644 index 0000000000..3ec71dc73c --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/codex_detector.go @@ -0,0 +1,164 @@ +// Package process provides background process execution and output streaming for agentctl. +// +// CodexDetector implements agent-specific TUI pattern matching for OpenAI Codex CLI +// to detect states: Working, WaitingApproval, WaitingInput. +// +// Codex requires a stability window because its TUI has intermittent output during +// working state - we need to ensure we don't falsely exit working state. + +package process + +import ( + "regexp" + "strings" + "sync" + "time" + + "github.com/tuzig/vt10x" +) + +// CodexDetector detects Codex CLI states by analyzing the terminal TUI. +type CodexDetector struct { + mu sync.Mutex + lastWorkingDetected time.Time +} + +// NewCodexDetector creates a new Codex detector. +func NewCodexDetector() *CodexDetector { + return &CodexDetector{} +} + +const ( + // codexMinWorkingExitInterval prevents false exits from working state + // Codex has intermittent output during processing, so we require 1 second + // of stability before accepting a transition out of working state. + codexMinWorkingExitInterval = 1000 * time.Millisecond +) + +var ( + // Working State Detection + // Pattern: Bullet + action + timer + // Example: "• Working (65s • esc to interrupt)" + // Example: "◦ Processing request (2m 30s • esc to interrupt)" + codexWorkingPattern = regexp.MustCompile( + `^[•◦]\s*.+\(?(\d+h\s+)?(\d+m\s+)?\d+s\s*[•·]\s*(esc|ctrl\+c)\s+to\s+interrup(t)?\)?`, + ) + + // Worked Line Pattern (task complete, waiting for input) + // Example: "─ Worked for 2m 30s─────────" + codexWorkedPattern = regexp.MustCompile(`^─\s*Worked\s+for\s+.+─+$`) + + // Starting MCP servers noise (should be ignored during detection) + mcpServersPattern = regexp.MustCompile(`(?i)starting\s+mcp\s+servers?`) + + // Codex approval patterns + // Selection arrow with numbered option + codexSelectionPattern = regexp.MustCompile(`^[›❯]\s*\d+\.\s+`) + + // Confirmation prompt + codexConfirmPattern = regexp.MustCompile(`(?i)press\s+enter\s+to\s+confirm`) + + // Cancel prompt + codexCancelPattern = regexp.MustCompile(`(?i)esc\s+to\s+cancel`) + + // Approval required prompt + codexApprovalPattern = regexp.MustCompile(`(?i)(approve|allow|confirm|proceed)\s*\?`) +) + +// DetectState examines the visible terminal content and returns the detected state. +func (d *CodexDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { + // Check for approval prompts first + if state := d.detectApproval(lines); state != StateUnknown { + return state + } + + // Check for working and waiting states + for _, line := range lines { + line = strings.TrimRight(line, " \t") + + // Skip MCP servers noise + if mcpServersPattern.MatchString(line) { + continue + } + + // Check for working pattern + if codexWorkingPattern.MatchString(line) { + d.mu.Lock() + d.lastWorkingDetected = time.Now() + d.mu.Unlock() + return StateWorking + } + + // Check for worked pattern (completed, waiting for input) + if codexWorkedPattern.MatchString(line) { + return StateWaitingInput + } + } + + return StateUnknown +} + +// ShouldAcceptStateChange determines if a state transition should be accepted. +// Codex needs a stability window when exiting working state to prevent false transitions. +func (d *CodexDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { + // If exiting working state, require stability window + if currentState == StateWorking && newState != StateWorking { + d.mu.Lock() + lastWorking := d.lastWorkingDetected + d.mu.Unlock() + + if time.Since(lastWorking) < codexMinWorkingExitInterval { + // Don't accept - not enough time has passed since last working detection + return false + } + } + + return true +} + +// detectApproval detects approval prompts in Codex TUI. +func (d *CodexDetector) detectApproval(lines []string) AgentState { + hasSelectionArrow := false + hasConfirmPrompt := false + + for i, line := range lines { + line = strings.TrimRight(line, " \t") + + // Check for selection arrow + if codexSelectionPattern.MatchString(line) { + hasSelectionArrow = true + + // Check nearby lines for confirmation prompt + for j := i + 1; j < len(lines) && j < i+5; j++ { + nearbyLine := strings.TrimRight(lines[j], " \t") + if codexConfirmPattern.MatchString(nearbyLine) || + codexCancelPattern.MatchString(nearbyLine) { + hasConfirmPrompt = true + break + } + } + + if hasConfirmPrompt { + return StateWaitingApproval + } + } + + // Direct approval pattern + if codexApprovalPattern.MatchString(line) { + return StateWaitingApproval + } + + // Standalone confirm/cancel pattern + if codexConfirmPattern.MatchString(line) && codexCancelPattern.MatchString(line) { + return StateWaitingApproval + } + } + + // If we found a selection arrow without explicit confirmation, + // still consider it as potentially needing approval + if hasSelectionArrow { + return StateWaitingApproval + } + + return StateUnknown +} diff --git a/apps/backend/internal/agentctl/server/process/codex_detector_test.go b/apps/backend/internal/agentctl/server/process/codex_detector_test.go new file mode 100644 index 0000000000..246c4fd5dd --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/codex_detector_test.go @@ -0,0 +1,231 @@ +package process + +import ( + "testing" + "time" + + "github.com/tuzig/vt10x" +) + +func TestCodexDetector_DetectState_Working(t *testing.T) { + detector := NewCodexDetector() + + tests := []struct { + name string + lines []string + expected AgentState + }{ + { + name: "working with seconds timer", + lines: []string{ + "• Working (65s • esc to interrupt)", + }, + expected: StateWorking, + }, + { + name: "working with minutes and seconds", + lines: []string{ + "• Processing request (2m 30s • esc to interrupt)", + }, + expected: StateWorking, + }, + { + name: "working with hollow bullet", + lines: []string{ + "◦ Analyzing (5s • esc to interrupt)", + }, + expected: StateWorking, + }, + { + name: "working with hours minutes seconds", + lines: []string{ + "• Long task (1h 30m 45s • esc to interrupt)", + }, + expected: StateWorking, + }, + { + name: "working with ctrl+c", + lines: []string{ + "• Working (10s • ctrl+c to interrupt)", + }, + expected: StateWorking, + }, + { + name: "not working - MCP servers noise", + lines: []string{ + "Starting MCP servers...", + "• Working (5s • esc to interrupt)", + }, + expected: StateWorking, // Should still detect working despite MCP noise + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + glyphs := make([][]vt10x.Glyph, len(tt.lines)) + for i, line := range tt.lines { + glyphs[i] = make([]vt10x.Glyph, len(line)) + } + + result := detector.DetectState(tt.lines, glyphs) + if result != tt.expected { + t.Errorf("DetectState() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCodexDetector_DetectState_WaitingInput(t *testing.T) { + detector := NewCodexDetector() + + tests := []struct { + name string + lines []string + expected AgentState + }{ + { + name: "worked line indicates completion", + lines: []string{ + "─ Worked for 2m 30s────────────────", + }, + expected: StateWaitingInput, + }, + { + name: "worked line with different duration", + lines: []string{ + "─ Worked for 45s─────────────────────", + }, + expected: StateWaitingInput, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + glyphs := make([][]vt10x.Glyph, len(tt.lines)) + for i, line := range tt.lines { + glyphs[i] = make([]vt10x.Glyph, len(line)) + } + + result := detector.DetectState(tt.lines, glyphs) + if result != tt.expected { + t.Errorf("DetectState() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCodexDetector_DetectState_WaitingApproval(t *testing.T) { + detector := NewCodexDetector() + + tests := []struct { + name string + lines []string + expected AgentState + }{ + { + name: "selection with confirm prompt", + lines: []string{ + "› 1. Option one", + " 2. Option two", + "Press enter to confirm", + }, + expected: StateWaitingApproval, + }, + { + name: "selection with cancel prompt", + lines: []string{ + "❯ 1. First choice", + "esc to cancel", + }, + expected: StateWaitingApproval, + }, + { + name: "approval question with question mark", + lines: []string{ + "Do you approve?", + }, + expected: StateWaitingApproval, + }, + { + name: "confirm question with question mark", + lines: []string{ + "Please confirm?", + }, + expected: StateWaitingApproval, + }, + { + name: "confirm and cancel on same line", + lines: []string{ + "press enter to confirm, esc to cancel", + }, + expected: StateWaitingApproval, + }, + { + name: "standalone selection arrow", + lines: []string{ + "› 1. Some option", + }, + expected: StateWaitingApproval, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + glyphs := make([][]vt10x.Glyph, len(tt.lines)) + for i, line := range tt.lines { + glyphs[i] = make([]vt10x.Glyph, len(line)) + } + + result := detector.DetectState(tt.lines, glyphs) + if result != tt.expected { + t.Errorf("DetectState() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCodexDetector_ShouldAcceptStateChange_StabilityWindow(t *testing.T) { + detector := NewCodexDetector() + + // Set last working time to now + detector.mu.Lock() + detector.lastWorkingDetected = time.Now() + detector.mu.Unlock() + + // Exiting working state immediately should be rejected + if detector.ShouldAcceptStateChange(StateWorking, StateWaitingInput) { + t.Error("ShouldAcceptStateChange should reject immediate exit from working state") + } + + // Wait for stability window + time.Sleep(codexMinWorkingExitInterval + 50*time.Millisecond) + + // Now it should be accepted + if !detector.ShouldAcceptStateChange(StateWorking, StateWaitingInput) { + t.Error("ShouldAcceptStateChange should accept exit from working state after stability window") + } +} + +func TestCodexDetector_ShouldAcceptStateChange_NonWorkingTransitions(t *testing.T) { + detector := NewCodexDetector() + + // Non-working state transitions should always be accepted + tests := []struct { + current AgentState + new AgentState + expected bool + }{ + {StateUnknown, StateWorking, true}, + {StateUnknown, StateWaitingInput, true}, + {StateWaitingInput, StateWorking, true}, + {StateWaitingApproval, StateWaitingInput, true}, + } + + for _, tt := range tests { + result := detector.ShouldAcceptStateChange(tt.current, tt.new) + if result != tt.expected { + t.Errorf("ShouldAcceptStateChange(%v, %v) = %v, want %v", + tt.current, tt.new, result, tt.expected) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go new file mode 100644 index 0000000000..c06fb98399 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -0,0 +1,886 @@ +// Package process provides background process execution and output streaming for agentctl. +// +// InteractiveRunner extends the pattern from ProcessRunner to support interactive +// CLI passthrough sessions where users interact directly with agent CLIs through +// a PTY-backed terminal. + +package process + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "sync" + "syscall" + "time" + + "github.com/creack/pty" + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// InteractiveStartRequest contains parameters for starting an interactive passthrough process. +type InteractiveStartRequest struct { + SessionID string `json:"session_id"` // Required: Agent session owning this process + Command []string `json:"command"` // Required: Command and args to execute + WorkingDir string `json:"working_dir"` // Working directory + Env map[string]string `json:"env,omitempty"` // Additional environment variables + PromptPattern string `json:"prompt_pattern,omitempty"` // Regex pattern to detect agent prompt for turn completion + IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` // Idle timeout in ms for turn detection + BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` // Max output buffer size + StatusDetector string `json:"status_detector,omitempty"` // Status detector type: "claude_code", "codex", "" + CheckIntervalMs int `json:"check_interval_ms,omitempty"` // How often to check state (default 100ms) + StabilityWindowMs int `json:"stability_window_ms,omitempty"` // State stability window (default 0) + ImmediateStart bool `json:"immediate_start,omitempty"` // Start immediately with default dimensions (don't wait for resize) + DefaultCols int `json:"default_cols,omitempty"` // Default columns if ImmediateStart (default 120) + DefaultRows int `json:"default_rows,omitempty"` // Default rows if ImmediateStart (default 40) +} + +// InteractiveProcessInfo represents the state of an interactive process. +type InteractiveProcessInfo struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Command []string `json:"command"` + WorkingDir string `json:"working_dir"` + Status types.ProcessStatus `json:"status"` + ExitCode *int `json:"exit_code,omitempty"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + Output []ProcessOutputChunk `json:"output,omitempty"` +} + +// DirectOutputWriter is a writer that receives raw PTY output. +// When set, output bypasses the event bus and goes directly to this writer. +type DirectOutputWriter interface { + io.Writer + io.Closer +} + +// interactiveProcess represents a running interactive PTY process. +type interactiveProcess struct { + info InteractiveProcessInfo + cmd *exec.Cmd + ptmx *os.File // PTY master file descriptor (using creack/pty) + buffer *ringBuffer + + // Turn detection + promptPattern *regexp.Regexp + idleTimeout time.Duration + idleTimer *time.Timer + idleTimerMu sync.Mutex + + // Status tracking (vt10x-based TUI detection) + statusTracker *StatusTracker + lastState AgentState + + // Deferred start - process created lazily on first resize + // This ensures PTY is created at exact frontend dimensions + started bool + startOnce sync.Once + startCmd []string + startDir string + startEnv map[string]string + startReq InteractiveStartRequest // Full request for deferred initialization + + // Direct output - when set, raw output goes here instead of event bus + directOutput DirectOutputWriter + directOutputMu sync.RWMutex + + // Lifecycle + stopOnce sync.Once + stopSignal chan struct{} + mu sync.Mutex +} + +// TurnCompleteCallback is called when turn detection determines the agent is waiting for input. +type TurnCompleteCallback func(sessionID string) + +// OutputCallback is called when process output is received. +// Used when running without a WorkspaceTracker (e.g., standalone passthrough mode). +type OutputCallback func(output *types.ProcessOutput) + +// StatusCallback is called when process status changes. +// Used when running without a WorkspaceTracker (e.g., standalone passthrough mode). +type StatusCallback func(status *types.ProcessStatusUpdate) + +// AgentStateCallback is called when agent TUI state changes (working, waiting, etc.). +type AgentStateCallback func(sessionID string, state AgentState) + +// InteractiveRunner manages interactive PTY-based processes with stdin support. +type InteractiveRunner struct { + logger *logger.Logger + workspaceTracker *WorkspaceTracker + bufferMaxBytes int64 + turnCompleteCallback TurnCompleteCallback + outputCallback OutputCallback + statusCallback StatusCallback + stateCallback AgentStateCallback + + mu sync.RWMutex + processes map[string]*interactiveProcess +} + +// NewInteractiveRunner creates a new interactive process runner. +func NewInteractiveRunner(workspaceTracker *WorkspaceTracker, log *logger.Logger, bufferMaxBytes int64) *InteractiveRunner { + return &InteractiveRunner{ + logger: log.WithFields(zap.String("component", "interactive-runner")), + workspaceTracker: workspaceTracker, + bufferMaxBytes: bufferMaxBytes, + processes: make(map[string]*interactiveProcess), + } +} + +// SetTurnCompleteCallback sets the callback to invoke when turn detection fires. +func (r *InteractiveRunner) SetTurnCompleteCallback(cb TurnCompleteCallback) { + r.turnCompleteCallback = cb +} + +// SetOutputCallback sets the callback to invoke when process output is received. +// This is used when running without a WorkspaceTracker. +func (r *InteractiveRunner) SetOutputCallback(cb OutputCallback) { + r.outputCallback = cb +} + +// SetStatusCallback sets the callback to invoke when process status changes. +// This is used when running without a WorkspaceTracker. +func (r *InteractiveRunner) SetStatusCallback(cb StatusCallback) { + r.statusCallback = cb +} + +// SetStateCallback sets the callback to invoke when agent TUI state changes. +func (r *InteractiveRunner) SetStateCallback(cb AgentStateCallback) { + r.stateCallback = cb +} + +// createStatusDetector creates the appropriate detector based on the detector type. +func createStatusDetector(detectorType string) StatusDetector { + switch detectorType { + case "claude_code": + return NewClaudeCodeDetector() + case "codex": + return NewCodexDetector() + default: + return &NoOpDetector{} + } +} + +// Start creates an interactive process entry and defers PTY creation until first resize. +// This ensures the PTY is created at exact frontend dimensions, preventing redraw issues. +func (r *InteractiveRunner) Start(ctx context.Context, req InteractiveStartRequest) (*InteractiveProcessInfo, error) { + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if len(req.Command) == 0 { + return nil, fmt.Errorf("command is required") + } + + id := uuid.New().String() + now := time.Now().UTC() + + bufferMaxBytes := req.BufferMaxBytes + if bufferMaxBytes <= 0 { + bufferMaxBytes = r.bufferMaxBytes + } + + // Compile prompt pattern if provided + var promptPattern *regexp.Regexp + if req.PromptPattern != "" { + var compileErr error + promptPattern, compileErr = regexp.Compile(req.PromptPattern) + if compileErr != nil { + r.logger.Warn("failed to compile prompt pattern, turn detection may not work", + zap.String("pattern", req.PromptPattern), + zap.Error(compileErr)) + } + } + + idleTimeout := time.Duration(req.IdleTimeoutMs) * time.Millisecond + if idleTimeout <= 0 { + idleTimeout = 5 * time.Second // Default 5 seconds + } + + // Create process struct WITHOUT spawning PTY yet + // PTY will be created on first resize when we know the exact dimensions + proc := &interactiveProcess{ + info: InteractiveProcessInfo{ + ID: id, + SessionID: req.SessionID, + Command: req.Command, + WorkingDir: req.WorkingDir, + Status: types.ProcessStatusRunning, + StartedAt: now, + UpdatedAt: now, + }, + buffer: newRingBuffer(bufferMaxBytes), + promptPattern: promptPattern, + idleTimeout: idleTimeout, + lastState: StateUnknown, + stopSignal: make(chan struct{}), + // Store start parameters for deferred initialization + started: false, + startCmd: req.Command, + startDir: req.WorkingDir, + startEnv: req.Env, + startReq: req, + } + + r.mu.Lock() + r.processes[id] = proc + r.mu.Unlock() + + // If immediate start is requested, start with default dimensions + if req.ImmediateStart { + cols := req.DefaultCols + rows := req.DefaultRows + if cols <= 0 { + cols = 120 // Default width + } + if rows <= 0 { + rows = 40 // Default height + } + + var startErr error + proc.startOnce.Do(func() { + r.logger.Info("immediate start - starting process with default dimensions", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.Int("cols", cols), + zap.Int("rows", rows)) + startErr = r.startProcess(proc, cols, rows) + }) + if startErr != nil { + r.mu.Lock() + delete(r.processes, id) + r.mu.Unlock() + return nil, fmt.Errorf("failed to start process: %w", startErr) + } + + r.logger.Info("interactive process started immediately", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.Strings("command", req.Command), + zap.String("working_dir", req.WorkingDir), + ) + } else { + r.logger.Info("interactive process created (waiting for terminal dimensions)", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.Strings("command", req.Command), + zap.String("working_dir", req.WorkingDir), + ) + } + + r.publishStatus(proc) + + info := proc.snapshot(false) + return &info, nil +} + +// startProcess actually spawns the PTY and process. Called on first resize. +func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows int) error { + req := proc.startReq + + // Build command - use Background context so the process lives beyond the request + // The process lifecycle is managed by Stop() and wait(), not by context cancellation + cmd := exec.Command(proc.startCmd[0], proc.startCmd[1:]...) + if proc.startDir != "" { + cmd.Dir = proc.startDir + } + cmd.Env = mergeEnv(proc.startEnv) + // Note: Do NOT set Setpgid when using PTY - it conflicts with terminal control + // The PTY session handles process group management + + // Start process in PTY with exact dimensions from frontend using creack/pty + // This is the battle-tested PTY library that properly handles resize/SIGWINCH + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + }) + if err != nil { + return fmt.Errorf("failed to start pty: %w", err) + } + + // Create status tracker if a detector is configured + var statusTracker *StatusTracker + if req.StatusDetector != "" { + detector := createStatusDetector(req.StatusDetector) + config := StatusTrackerConfig{ + Rows: rows, + Cols: cols, + CheckInterval: time.Duration(req.CheckIntervalMs) * time.Millisecond, + StabilityWindow: time.Duration(req.StabilityWindowMs) * time.Millisecond, + } + if config.CheckInterval <= 0 { + config.CheckInterval = 100 * time.Millisecond + } + // Create callback that will invoke the runner's state callback + stateCallback := func(sessionID string, state AgentState) { + if r.stateCallback != nil { + r.stateCallback(sessionID, state) + } + } + statusTracker = NewStatusTracker(req.SessionID, detector, stateCallback, config, r.logger) + r.logger.Debug("status tracker created", + zap.String("session_id", req.SessionID), + zap.String("detector", req.StatusDetector)) + } + + proc.mu.Lock() + proc.ptmx = ptmx + proc.cmd = cmd + proc.statusTracker = statusTracker + proc.started = true + proc.mu.Unlock() + + pid := 0 + if cmd.Process != nil { + pid = cmd.Process.Pid + } + r.logger.Info("interactive process started at exact dimensions", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.Int("cols", cols), + zap.Int("rows", rows), + zap.Int("pid", pid), + ) + + // Start output reading and process waiting goroutines + go r.readOutput(proc) + go r.wait(proc) + + return nil +} + +// WriteStdin writes data to the process stdin (through PTY). +func (r *InteractiveRunner) WriteStdin(processID string, data string) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + proc.mu.Lock() + started := proc.started + ptyInstance := proc.ptmx + proc.mu.Unlock() + + if !started { + return fmt.Errorf("process not started yet - waiting for terminal dimensions") + } + + if ptyInstance == nil { + return fmt.Errorf("process stdin not available") + } + + _, err := ptyInstance.Write([]byte(data)) + if err != nil { + return fmt.Errorf("failed to write to stdin: %w", err) + } + + // Reset idle timer when user sends input + r.resetIdleTimer(proc) + + return nil +} + +// Stop terminates an interactive process. +func (r *InteractiveRunner) Stop(ctx context.Context, processID string) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + // Signal output reader to exit + proc.stopOnce.Do(func() { + close(proc.stopSignal) + }) + + // Stop idle timer + proc.idleTimerMu.Lock() + if proc.idleTimer != nil { + proc.idleTimer.Stop() + } + proc.idleTimerMu.Unlock() + + // Close PTY (this will cause the process to receive SIGHUP) + proc.mu.Lock() + if proc.ptmx != nil { + _ = proc.ptmx.Close() + } + proc.mu.Unlock() + + // Terminate the process directly (PTY handles its own session management) + if proc.cmd != nil && proc.cmd.Process != nil { + _ = proc.cmd.Process.Signal(syscall.SIGTERM) + + // Wait for graceful exit, then force kill + done := make(chan struct{}) + go func() { + _ = proc.cmd.Wait() + close(done) + }() + + select { + case <-ctx.Done(): + _ = proc.cmd.Process.Kill() + case <-time.After(2 * time.Second): + _ = proc.cmd.Process.Kill() + case <-done: + // Process exited cleanly + } + } + + return nil +} + +// Get retrieves process information by ID. +func (r *InteractiveRunner) Get(id string, includeOutput bool) (*InteractiveProcessInfo, bool) { + proc, ok := r.get(id) + if !ok { + return nil, false + } + info := proc.snapshot(includeOutput) + return &info, true +} + +// GetBySession retrieves process information by session ID. +func (r *InteractiveRunner) GetBySession(sessionID string) (*InteractiveProcessInfo, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, proc := range r.processes { + if proc.info.SessionID == sessionID { + info := proc.snapshot(false) + return &info, true + } + } + return nil, false +} + +// GetBuffer returns the buffered output for a process. +func (r *InteractiveRunner) GetBuffer(processID string) ([]ProcessOutputChunk, bool) { + proc, ok := r.get(processID) + if !ok { + return nil, false + } + return proc.buffer.snapshot(), true +} + +// ResizeBySession resizes the PTY for a process by session ID. +// On first resize, this triggers lazy process start at the exact frontend dimensions. +func (r *InteractiveRunner) ResizeBySession(sessionID string, cols, rows uint16) error { + r.mu.RLock() + var proc *interactiveProcess + for _, p := range r.processes { + if p.info.SessionID == sessionID { + proc = p + break + } + } + r.mu.RUnlock() + + if proc == nil { + return fmt.Errorf("no process found for session %s", sessionID) + } + + // Lazy start: spawn process on first resize when we have exact dimensions + var startErr error + proc.startOnce.Do(func() { + r.logger.Info("first resize received - starting process", + zap.String("session_id", sessionID), + zap.Uint16("cols", cols), + zap.Uint16("rows", rows)) + startErr = r.startProcess(proc, int(cols), int(rows)) + }) + if startErr != nil { + return fmt.Errorf("failed to start process on first resize: %w", startErr) + } + + proc.mu.Lock() + ptyInstance := proc.ptmx + statusTracker := proc.statusTracker + proc.mu.Unlock() + + // If process started, resize the PTY using creack/pty's Setsize + // This properly sets the window size and sends SIGWINCH to the process + if ptyInstance != nil { + if err := pty.Setsize(ptyInstance, &pty.Winsize{ + Cols: cols, + Rows: rows, + }); err != nil { + return fmt.Errorf("failed to resize PTY: %w", err) + } + } + + // Also resize the status tracker's virtual terminal + if statusTracker != nil { + statusTracker.Resize(int(cols), int(rows)) + } + + r.logger.Debug("resized PTY", + zap.String("session_id", sessionID), + zap.Uint16("cols", cols), + zap.Uint16("rows", rows)) + + return nil +} + +func (r *InteractiveRunner) get(id string) (*interactiveProcess, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + proc, ok := r.processes[id] + return proc, ok +} + +func (r *InteractiveRunner) readOutput(proc *interactiveProcess) { + buf := make([]byte, 32768) // 32KB buffer for better throughput + recentOutput := "" // Keep recent output for prompt pattern matching + firstRead := true + + for { + select { + case <-proc.stopSignal: + r.logger.Debug("readOutput: stop signal received", + zap.String("process_id", proc.info.ID)) + return + default: + } + + proc.mu.Lock() + ptyInstance := proc.ptmx + proc.mu.Unlock() + + if ptyInstance == nil { + r.logger.Debug("readOutput: pty is nil, exiting", + zap.String("process_id", proc.info.ID)) + return + } + + n, err := ptyInstance.Read(buf) + if firstRead { + r.logger.Info("readOutput: first read attempt", + zap.String("process_id", proc.info.ID), + zap.Int("bytes", n), + zap.Error(err)) + firstRead = false + } + if n > 0 { + data := buf[:n] + dataStr := string(data) + + // Feed to status tracker for TUI state detection + if proc.statusTracker != nil { + proc.statusTracker.Write(data) + + // Periodically check state (debounced by ShouldCheck) + if proc.statusTracker.ShouldCheck() { + newState := proc.statusTracker.CheckAndUpdate() + if newState != proc.lastState { + proc.lastState = newState + r.handleStateChange(proc, newState) + } + } + } + + // Check if we have a direct output writer (binary WebSocket mode) + proc.directOutputMu.RLock() + directWriter := proc.directOutput + proc.directOutputMu.RUnlock() + + if directWriter != nil { + // Direct mode: write raw bytes to the WebSocket, skip event bus + if _, writeErr := directWriter.Write(data); writeErr != nil { + r.logger.Debug("direct output write error", + zap.String("process_id", proc.info.ID), + zap.Error(writeErr)) + // Don't return - the writer might have closed but process continues + } + } else { + // Normal mode: buffer and stream output via event bus + chunk := ProcessOutputChunk{ + Stream: "stdout", + Data: dataStr, + Timestamp: time.Now().UTC(), + } + proc.buffer.append(chunk) + r.publishOutput(proc, chunk) + } + + // Update recent output for prompt pattern matching (keep last 1KB) + // Trim before appending to prevent temporary memory spikes with large outputs + maxSize := 1024 + if len(recentOutput)+len(dataStr) > maxSize { + // Calculate how much to keep from existing buffer + keepFromExisting := maxSize - len(dataStr) + if keepFromExisting < 0 { + keepFromExisting = 0 + } + if keepFromExisting > 0 && len(recentOutput) > keepFromExisting { + recentOutput = recentOutput[len(recentOutput)-keepFromExisting:] + } else if keepFromExisting == 0 { + recentOutput = "" + } + } + recentOutput += dataStr + // Final trim in case dataStr itself was larger than maxSize + if len(recentOutput) > maxSize { + recentOutput = recentOutput[len(recentOutput)-maxSize:] + } + + // Check prompt pattern for turn completion + if proc.promptPattern != nil && proc.promptPattern.MatchString(recentOutput) { + r.emitTurnComplete(proc) + recentOutput = "" // Reset after match + } + + // Reset idle timer on any output + r.resetIdleTimer(proc) + } + if err != nil { + r.logger.Debug("interactive process output read ended", + zap.String("process_id", proc.info.ID), + zap.Error(err)) + return + } + } +} + +func (r *InteractiveRunner) resetIdleTimer(proc *interactiveProcess) { + proc.idleTimerMu.Lock() + defer proc.idleTimerMu.Unlock() + + if proc.idleTimer != nil { + proc.idleTimer.Stop() + } + + if proc.idleTimeout > 0 { + proc.idleTimer = time.AfterFunc(proc.idleTimeout, func() { + r.emitTurnComplete(proc) + }) + } +} + +func (r *InteractiveRunner) emitTurnComplete(proc *interactiveProcess) { + if r.turnCompleteCallback != nil { + r.turnCompleteCallback(proc.info.SessionID) + } + r.logger.Debug("turn complete detected", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID)) +} + +// handleStateChange processes agent state changes detected by the status tracker. +func (r *InteractiveRunner) handleStateChange(proc *interactiveProcess, state AgentState) { + r.logger.Debug("agent state changed", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.String("state", string(state))) + + // WaitingInput state triggers turn complete + if state == StateWaitingInput { + r.emitTurnComplete(proc) + } + + // Invoke the state callback for external handling + if r.stateCallback != nil { + r.stateCallback(proc.info.SessionID, state) + } +} + +// wait blocks until the process exits and then cleans up. +// Note: cmd.Wait() is intentionally blocking without a timeout. This is the correct +// behavior because: +// 1. Wait() is required to reap the process and prevent zombies +// 2. Stuck processes should be terminated via Stop() which sends SIGTERM/SIGKILL +// 3. Adding a timeout here would leave the process unreachable and create leaks +func (r *InteractiveRunner) wait(proc *interactiveProcess) { + err := proc.cmd.Wait() + exitCode := 0 + status := types.ProcessStatusExited + var signalName string + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if waitStatus, ok := exitErr.Sys().(syscall.WaitStatus); ok { + if waitStatus.Signaled() { + signalName = waitStatus.Signal().String() + exitCode = 128 + int(waitStatus.Signal()) + } else { + exitCode = waitStatus.ExitStatus() + } + } else { + exitCode = 1 + } + } else { + exitCode = 1 + } + status = types.ProcessStatusFailed + } + + r.logger.Info("interactive process exited", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.String("status", string(status)), + zap.Int("exit_code", exitCode), + zap.String("signal", signalName), + zap.Error(err), + ) + + // Stop idle timer + proc.idleTimerMu.Lock() + if proc.idleTimer != nil { + proc.idleTimer.Stop() + } + proc.idleTimerMu.Unlock() + + // Update process info + proc.mu.Lock() + proc.info.Status = status + proc.info.ExitCode = &exitCode + proc.info.UpdatedAt = time.Now().UTC() + proc.mu.Unlock() + + // Close PTY + proc.mu.Lock() + if proc.ptmx != nil { + _ = proc.ptmx.Close() + proc.ptmx = nil + } + proc.mu.Unlock() + + r.publishStatus(proc) + + // Remove from tracking + r.mu.Lock() + delete(r.processes, proc.info.ID) + r.mu.Unlock() +} + +func (r *InteractiveRunner) publishOutput(proc *interactiveProcess, chunk ProcessOutputChunk) { + // No gating needed - process starts at exact frontend dimensions via lazy start + proc.mu.Lock() + info := proc.info + proc.mu.Unlock() + + output := &types.ProcessOutput{ + SessionID: info.SessionID, + ProcessID: info.ID, + Kind: types.ProcessKindAgentPassthrough, + Stream: chunk.Stream, + Data: chunk.Data, + Timestamp: chunk.Timestamp, + } + + // Use WorkspaceTracker if available, otherwise use callback + if r.workspaceTracker != nil { + r.workspaceTracker.notifyWorkspaceStreamProcessOutput(output) + } else if r.outputCallback != nil { + r.outputCallback(output) + } +} + +func (r *InteractiveRunner) publishStatus(proc *interactiveProcess) { + proc.mu.Lock() + info := proc.info + proc.mu.Unlock() + + // Convert []string command to single string for status update + cmdStr := "" + if len(info.Command) > 0 { + cmdStr = info.Command[0] + } + + update := &types.ProcessStatusUpdate{ + SessionID: info.SessionID, + ProcessID: info.ID, + Kind: types.ProcessKindAgentPassthrough, + Command: cmdStr, + WorkingDir: info.WorkingDir, + Status: info.Status, + ExitCode: info.ExitCode, + Timestamp: time.Now().UTC(), + } + + // Use WorkspaceTracker if available, otherwise use callback + if r.workspaceTracker != nil { + r.workspaceTracker.notifyWorkspaceStreamProcessStatus(update) + } else if r.statusCallback != nil { + r.statusCallback(update) + } +} + +func (p *interactiveProcess) snapshot(includeOutput bool) InteractiveProcessInfo { + p.mu.Lock() + defer p.mu.Unlock() + info := p.info + if includeOutput && p.buffer != nil { + info.Output = p.buffer.snapshot() + } + return info +} + +// SetDirectOutput sets a direct output writer for a process. +// When set, PTY output bypasses the event bus and goes directly to this writer. +// This is used for the dedicated binary WebSocket in passthrough mode. +// Returns error if process not found. +func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutputWriter) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + proc.directOutputMu.Lock() + proc.directOutput = writer + proc.directOutputMu.Unlock() + + r.logger.Info("direct output set for process", + zap.String("process_id", processID), + zap.String("session_id", proc.info.SessionID)) + + return nil +} + +// ClearDirectOutput clears the direct output writer for a process. +// Output will return to the normal event bus path. +func (r *InteractiveRunner) ClearDirectOutput(processID string) error { + proc, ok := r.get(processID) + if !ok { + return fmt.Errorf("process not found: %s", processID) + } + + proc.directOutputMu.Lock() + proc.directOutput = nil + proc.directOutputMu.Unlock() + + r.logger.Info("direct output cleared for process", + zap.String("process_id", processID), + zap.String("session_id", proc.info.SessionID)) + + return nil +} + +// GetPtyWriter returns a writer for sending input to the PTY. +// This is used for the dedicated binary WebSocket in passthrough mode. +func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) { + proc, ok := r.get(processID) + if !ok { + return nil, fmt.Errorf("process not found: %s", processID) + } + + proc.mu.Lock() + defer proc.mu.Unlock() + + if !proc.started { + return nil, fmt.Errorf("process not started yet - waiting for terminal dimensions") + } + + if proc.ptmx == nil { + return nil, fmt.Errorf("PTY not available") + } + + return proc.ptmx, nil +} + diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner_test.go b/apps/backend/internal/agentctl/server/process/interactive_runner_test.go new file mode 100644 index 0000000000..fbb6934a95 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/interactive_runner_test.go @@ -0,0 +1,497 @@ +package process + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +func TestInteractiveRunner_Start(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "test-session", + Command: []string{"echo", "hello"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + if info.ID == "" { + t.Error("Start() returned empty ID") + } + if info.SessionID != "test-session" { + t.Errorf("Start() SessionID = %q, want %q", info.SessionID, "test-session") + } + if info.Status != types.ProcessStatusRunning { + t.Errorf("Start() Status = %v, want %v", info.Status, types.ProcessStatusRunning) + } + + // Wait for process to exit + time.Sleep(500 * time.Millisecond) + + // Process should have completed + procInfo, ok := runner.Get(info.ID, false) + if !ok { + // Process may have been removed after exit, which is expected + return + } + if procInfo.Status == types.ProcessStatusRunning { + t.Error("Process should have exited") + } +} + +func TestInteractiveRunner_Start_ValidationErrors(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + tests := []struct { + name string + req InteractiveStartRequest + wantErr bool + }{ + { + name: "missing session_id", + req: InteractiveStartRequest{ + Command: []string{"echo"}, + }, + wantErr: true, + }, + { + name: "missing command", + req: InteractiveStartRequest{ + SessionID: "test", + }, + wantErr: true, + }, + { + name: "valid request", + req: InteractiveStartRequest{ + SessionID: "test", + Command: []string{"echo"}, + ImmediateStart: true, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runner.Start(context.Background(), tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("Start() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestInteractiveRunner_DeferredStart(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start without ImmediateStart - process should be deferred + // Use 'cat' which blocks waiting for input, giving us time to check status + req := InteractiveStartRequest{ + SessionID: "deferred-session", + Command: []string{"cat"}, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Try to write - should fail because process not started + err = runner.WriteStdin(info.ID, "test") + if err == nil { + t.Error("WriteStdin() should fail for deferred process") + } + + // Trigger start via resize + err = runner.ResizeBySession("deferred-session", 80, 24) + if err != nil { + t.Fatalf("ResizeBySession() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Now get process info - process should exist and be running + procInfo, ok := runner.GetBySession("deferred-session") + if !ok { + t.Fatal("GetBySession() should find process after resize") + } + if procInfo.Status != types.ProcessStatusRunning { + t.Errorf("Process status = %v, want running", procInfo.Status) + } + + // Clean up - stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} + +func TestInteractiveRunner_WriteStdin(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start cat process that echoes input + req := InteractiveStartRequest{ + SessionID: "stdin-test", + Command: []string{"cat"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Write to stdin + err = runner.WriteStdin(info.ID, "hello\n") + if err != nil { + t.Errorf("WriteStdin() error = %v", err) + } + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} + +func TestInteractiveRunner_Stop(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start a long-running process + req := InteractiveStartRequest{ + SessionID: "stop-test", + Command: []string{"sleep", "60"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = runner.Stop(ctx, info.ID) + if err != nil { + t.Errorf("Stop() error = %v", err) + } + + // Process should be removed after stop + time.Sleep(200 * time.Millisecond) + _, ok := runner.Get(info.ID, false) + if ok { + t.Error("Process should be removed after stop") + } +} + +func TestInteractiveRunner_GetBuffer(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "buffer-test", + Command: []string{"echo", "buffered output"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Wait for output + time.Sleep(500 * time.Millisecond) + + buffer, ok := runner.GetBuffer(info.ID) + if !ok { + // Process may have exited and been removed + return + } + + // Check if output was captured + combined := "" + for _, chunk := range buffer { + combined += chunk.Data + } + + if !strings.Contains(combined, "buffered") { + t.Logf("Buffer contents: %q", combined) + // Note: Output might be empty if process exited too quickly + } +} + +func TestInteractiveRunner_Callbacks(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + var statusReceived bool + var mu sync.Mutex + + runner.SetOutputCallback(func(output *types.ProcessOutput) { + // Output callback received + }) + + runner.SetStatusCallback(func(status *types.ProcessStatusUpdate) { + mu.Lock() + statusReceived = true + mu.Unlock() + }) + + req := InteractiveStartRequest{ + SessionID: "callback-test", + Command: []string{"echo", "callback test"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + _, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Wait for callbacks + time.Sleep(500 * time.Millisecond) + + mu.Lock() + if !statusReceived { + t.Error("Status callback should have been called") + } + // Output callback may or may not be called depending on timing + mu.Unlock() +} + +func TestInteractiveRunner_TurnCompleteCallback(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + var turnCompleteCalled bool + var turnSessionID string + var mu sync.Mutex + + runner.SetTurnCompleteCallback(func(sessionID string) { + mu.Lock() + turnCompleteCalled = true + turnSessionID = sessionID + mu.Unlock() + }) + + // Start with a prompt pattern that matches "$ " + req := InteractiveStartRequest{ + SessionID: "turn-test", + Command: []string{"bash", "-c", "echo '$ '"}, + PromptPattern: `\$ $`, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + _, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Wait for turn detection + time.Sleep(500 * time.Millisecond) + + mu.Lock() + if turnCompleteCalled && turnSessionID != "turn-test" { + t.Errorf("Turn complete callback received wrong session ID: %q", turnSessionID) + } + mu.Unlock() +} + +func TestInteractiveRunner_DirectOutput(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "direct-output-test", + Command: []string{"echo", "direct"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Create a mock direct writer + writer := &mockDirectWriter{} + + // Set direct output + err = runner.SetDirectOutput(info.ID, writer) + if err != nil { + t.Errorf("SetDirectOutput() error = %v", err) + } + + // Wait for output + time.Sleep(200 * time.Millisecond) + + // Clear direct output + err = runner.ClearDirectOutput(info.ID) + // May fail if process already exited, that's OK + _ = err + + // Check if writer received data + writer.mu.Lock() + gotData := len(writer.data) > 0 + writer.mu.Unlock() + + if gotData { + t.Log("Direct writer received data") + } +} + +// mockDirectWriter implements DirectOutputWriter for testing +type mockDirectWriter struct { + mu sync.Mutex + data []byte + closed bool +} + +func (w *mockDirectWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + w.data = append(w.data, p...) + return len(p), nil +} + +func (w *mockDirectWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + w.closed = true + return nil +} + +func TestInteractiveRunner_GetPtyWriter(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + req := InteractiveStartRequest{ + SessionID: "pty-writer-test", + Command: []string{"cat"}, + ImmediateStart: true, + DefaultCols: 80, + DefaultRows: 24, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Give process time to start + time.Sleep(100 * time.Millisecond) + + // Get PTY writer + writer, err := runner.GetPtyWriter(info.ID) + if err != nil { + t.Fatalf("GetPtyWriter() error = %v", err) + } + + if writer == nil { + t.Error("GetPtyWriter() returned nil writer") + } + + // Stop the process + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = runner.Stop(ctx, info.ID) +} + +func TestInteractiveRunner_GetPtyWriter_NotStarted(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start without ImmediateStart + req := InteractiveStartRequest{ + SessionID: "not-started", + Command: []string{"cat"}, + } + + info, err := runner.Start(context.Background(), req) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Try to get PTY writer before process starts + _, err = runner.GetPtyWriter(info.ID) + if err == nil { + t.Error("GetPtyWriter() should fail for deferred process") + } +} + +func TestInteractiveRunner_NotFound(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Test various methods with non-existent process + _, ok := runner.Get("nonexistent", false) + if ok { + t.Error("Get() should return false for nonexistent process") + } + + _, ok = runner.GetBySession("nonexistent") + if ok { + t.Error("GetBySession() should return false for nonexistent session") + } + + _, ok = runner.GetBuffer("nonexistent") + if ok { + t.Error("GetBuffer() should return false for nonexistent process") + } + + err := runner.WriteStdin("nonexistent", "data") + if err == nil { + t.Error("WriteStdin() should fail for nonexistent process") + } + + ctx := context.Background() + err = runner.Stop(ctx, "nonexistent") + if err == nil { + t.Error("Stop() should fail for nonexistent process") + } + + err = runner.SetDirectOutput("nonexistent", nil) + if err == nil { + t.Error("SetDirectOutput() should fail for nonexistent process") + } + + _, err = runner.GetPtyWriter("nonexistent") + if err == nil { + t.Error("GetPtyWriter() should fail for nonexistent process") + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 401794df48..38a9c75429 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -798,3 +798,33 @@ func (m *Manager) Shell() *shell.Session { defer m.mu.RUnlock() return m.shell } + +// StartShell creates and starts the shell session independently of the agent process. +// This is used in passthrough mode where the agent runs directly via InteractiveRunner +// but we still need shell access for the workspace. +// Returns nil if shell is already started or if ShellEnabled is false. +func (m *Manager) StartShell() error { + m.mu.Lock() + defer m.mu.Unlock() + + // Already running + if m.shell != nil { + return nil + } + + // Shell disabled + if !m.cfg.ShellEnabled { + return nil + } + + shellCfg := shell.DefaultConfig(m.cfg.WorkDir) + shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv) + shellSession, err := shell.NewSession(shellCfg, m.logger) + if err != nil { + return fmt.Errorf("failed to create shell session: %w", err) + } + + m.shell = shellSession + m.logger.Info("shell session started independently") + return nil +} diff --git a/apps/backend/internal/agentctl/server/process/status_tracker.go b/apps/backend/internal/agentctl/server/process/status_tracker.go new file mode 100644 index 0000000000..96835d5f6b --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/status_tracker.go @@ -0,0 +1,256 @@ +// Package process provides background process execution and output streaming for agentctl. +// +// StatusTracker uses a virtual terminal emulator (vt10x) to parse agent TUI output +// and detect agent states (working, waiting for approval, waiting for input). + +package process + +import ( + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/tuzig/vt10x" + "go.uber.org/zap" +) + +// AgentState represents the detected state of an agent CLI. +type AgentState string + +const ( + StateUnknown AgentState = "unknown" + StateWorking AgentState = "working" + StateWaitingApproval AgentState = "waiting_approval" + StateWaitingInput AgentState = "waiting_input" +) + +// StatusDetector is implemented by agent-specific detectors to detect state from terminal content. +type StatusDetector interface { + // DetectState examines the visible terminal content and returns the detected state. + // lines contains the visible text lines, glyphs contains the full glyph data with attributes. + DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState + + // ShouldAcceptStateChange determines if a state transition should be accepted. + // This allows detectors to implement stability windows for debouncing. + ShouldAcceptStateChange(currentState, newState AgentState) bool +} + +// StateChangeCallback is called when the detected state changes. +type StateChangeCallback func(sessionID string, state AgentState) + +// StatusTrackerConfig contains configuration for the status tracker. +type StatusTrackerConfig struct { + Rows int // Terminal rows (default 24) + Cols int // Terminal columns (default 80) + CheckInterval time.Duration // How often to check state (default 100ms) + StabilityWindow time.Duration // Time state must be stable before reporting (default 0) +} + +// DefaultStatusTrackerConfig returns the default configuration. +func DefaultStatusTrackerConfig() StatusTrackerConfig { + return StatusTrackerConfig{ + Rows: 24, + Cols: 80, + CheckInterval: 100 * time.Millisecond, + StabilityWindow: 0, + } +} + +// StatusTracker uses a virtual terminal emulator to track agent state. +type StatusTracker struct { + logger *logger.Logger + sessionID string + detector StatusDetector + callback StateChangeCallback + config StatusTrackerConfig + + // vt10x terminal emulator + term vt10x.Terminal + + // State tracking + mu sync.Mutex + lastState AgentState + lastStateChange time.Time + lastCheck time.Time + pendingState AgentState // State waiting for stability window + pendingStateTime time.Time // When pending state was first detected +} + +// NewStatusTracker creates a new status tracker with the given detector. +func NewStatusTracker( + sessionID string, + detector StatusDetector, + callback StateChangeCallback, + config StatusTrackerConfig, + log *logger.Logger, +) *StatusTracker { + if config.Rows <= 0 { + config.Rows = 24 + } + if config.Cols <= 0 { + config.Cols = 80 + } + if config.CheckInterval <= 0 { + config.CheckInterval = 100 * time.Millisecond + } + + // Create vt10x terminal emulator + term := vt10x.New(vt10x.WithSize(config.Cols, config.Rows)) + + return &StatusTracker{ + logger: log.WithFields(zap.String("component", "status-tracker"), zap.String("session_id", sessionID)), + sessionID: sessionID, + detector: detector, + callback: callback, + config: config, + term: term, + lastState: StateUnknown, + } +} + +// Write feeds data to the virtual terminal emulator. +// This should be called with all PTY output data. +func (t *StatusTracker) Write(data []byte) { + t.mu.Lock() + defer t.mu.Unlock() + + // Feed data to the virtual terminal + _, _ = t.term.Write(data) +} + +// Resize updates the virtual terminal size. +func (t *StatusTracker) Resize(cols, rows int) { + t.mu.Lock() + defer t.mu.Unlock() + + t.term.Resize(cols, rows) + t.config.Cols = cols + t.config.Rows = rows +} + +// ShouldCheck returns true if it's time to check the terminal state. +func (t *StatusTracker) ShouldCheck() bool { + t.mu.Lock() + defer t.mu.Unlock() + + return time.Since(t.lastCheck) >= t.config.CheckInterval +} + +// CheckAndUpdate checks the terminal state and emits a callback if the state changed. +// Returns the current state. +func (t *StatusTracker) CheckAndUpdate() AgentState { + t.mu.Lock() + defer t.mu.Unlock() + + t.lastCheck = time.Now() + + // Extract visible lines and glyphs from the terminal + lines, glyphs := t.extractTerminalContent() + + // Detect state using the agent-specific detector + detectedState := t.detector.DetectState(lines, glyphs) + + // Handle stability window if configured + if t.config.StabilityWindow > 0 { + return t.handleStabilityWindow(detectedState) + } + + // No stability window - check if detector accepts the transition + if detectedState != t.lastState { + if t.detector.ShouldAcceptStateChange(t.lastState, detectedState) { + t.emitStateChange(detectedState) + } + } + + return t.lastState +} + +// CurrentState returns the current detected state. +func (t *StatusTracker) CurrentState() AgentState { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastState +} + +// extractTerminalContent extracts the visible lines and glyphs from the vt10x terminal. +func (t *StatusTracker) extractTerminalContent() ([]string, [][]vt10x.Glyph) { + rows := t.config.Rows + cols := t.config.Cols + + lines := make([]string, rows) + glyphs := make([][]vt10x.Glyph, rows) + + for row := 0; row < rows; row++ { + rowGlyphs := make([]vt10x.Glyph, cols) + var rowChars []rune + + for col := 0; col < cols; col++ { + g := t.term.Cell(col, row) + rowGlyphs[col] = g + if g.Char == 0 { + rowChars = append(rowChars, ' ') + } else { + rowChars = append(rowChars, g.Char) + } + } + + lines[row] = string(rowChars) + glyphs[row] = rowGlyphs + } + + return lines, glyphs +} + +// handleStabilityWindow implements debouncing for state changes. +func (t *StatusTracker) handleStabilityWindow(detectedState AgentState) AgentState { + now := time.Now() + + if detectedState != t.pendingState { + // New state detected, start stability timer + t.pendingState = detectedState + t.pendingStateTime = now + return t.lastState + } + + // Same state as pending - check if stable long enough + if now.Sub(t.pendingStateTime) >= t.config.StabilityWindow { + if t.pendingState != t.lastState { + if t.detector.ShouldAcceptStateChange(t.lastState, t.pendingState) { + t.emitStateChange(t.pendingState) + } + } + } + + return t.lastState +} + +// emitStateChange updates the state and calls the callback. +// Must be called with mutex held. +func (t *StatusTracker) emitStateChange(newState AgentState) { + oldState := t.lastState + t.lastState = newState + t.lastStateChange = time.Now() + + t.logger.Debug("agent state changed", + zap.String("old_state", string(oldState)), + zap.String("new_state", string(newState))) + + if t.callback != nil { + // Release lock before callback to prevent deadlocks + t.mu.Unlock() + t.callback(t.sessionID, newState) + t.mu.Lock() + } +} + +// NoOpDetector is a detector that always returns StateUnknown. +// Used when no detector is configured. +type NoOpDetector struct{} + +func (d *NoOpDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { + return StateUnknown +} + +func (d *NoOpDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { + return true +} diff --git a/apps/backend/internal/agentctl/server/process/status_tracker_test.go b/apps/backend/internal/agentctl/server/process/status_tracker_test.go new file mode 100644 index 0000000000..2278114923 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/status_tracker_test.go @@ -0,0 +1,268 @@ +package process + +import ( + "sync" + "testing" + "time" + + "github.com/tuzig/vt10x" +) + +// mockDetector is a test detector that returns configurable states +type mockDetector struct { + state AgentState + acceptTransition bool +} + +func (d *mockDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { + return d.state +} + +func (d *mockDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { + return d.acceptTransition +} + +func TestStatusTracker_Write(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateUnknown, acceptTransition: true} + config := DefaultStatusTrackerConfig() + + tracker := NewStatusTracker("test-session", detector, nil, config, log) + + // Write some terminal data + tracker.Write([]byte("Hello, World!")) + + // The data should be fed to the terminal - we can't easily verify this + // without exposing internal state, but at least verify no panics +} + +func TestStatusTracker_Resize(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateUnknown, acceptTransition: true} + config := StatusTrackerConfig{ + Rows: 24, + Cols: 80, + CheckInterval: 100 * time.Millisecond, + } + + tracker := NewStatusTracker("test-session", detector, nil, config, log) + + // Resize the terminal + tracker.Resize(120, 40) + + // Verify config was updated + tracker.mu.Lock() + cols := tracker.config.Cols + rows := tracker.config.Rows + tracker.mu.Unlock() + + if cols != 120 { + t.Errorf("Resize() cols = %d, want 120", cols) + } + if rows != 40 { + t.Errorf("Resize() rows = %d, want 40", rows) + } +} + +func TestStatusTracker_ShouldCheck(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateUnknown, acceptTransition: true} + config := StatusTrackerConfig{ + Rows: 24, + Cols: 80, + CheckInterval: 50 * time.Millisecond, + } + + tracker := NewStatusTracker("test-session", detector, nil, config, log) + + // Should check immediately after creation + if !tracker.ShouldCheck() { + t.Error("ShouldCheck() should return true initially") + } + + // After checking, should not check again immediately + tracker.CheckAndUpdate() + + if tracker.ShouldCheck() { + t.Error("ShouldCheck() should return false right after check") + } + + // After waiting, should check again + time.Sleep(60 * time.Millisecond) + + if !tracker.ShouldCheck() { + t.Error("ShouldCheck() should return true after check interval") + } +} + +func TestStatusTracker_CheckAndUpdate_StateChange(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateWorking, acceptTransition: true} + + var callbackCalled bool + var callbackState AgentState + var callbackMu sync.Mutex + + callback := func(sessionID string, state AgentState) { + callbackMu.Lock() + callbackCalled = true + callbackState = state + callbackMu.Unlock() + } + + config := DefaultStatusTrackerConfig() + tracker := NewStatusTracker("test-session", detector, callback, config, log) + + // First check should emit the initial state change + state := tracker.CheckAndUpdate() + + if state != StateWorking { + t.Errorf("CheckAndUpdate() = %v, want %v", state, StateWorking) + } + + callbackMu.Lock() + if !callbackCalled { + t.Error("Callback should have been called on state change") + } + if callbackState != StateWorking { + t.Errorf("Callback received state %v, want %v", callbackState, StateWorking) + } + callbackMu.Unlock() +} + +func TestStatusTracker_CheckAndUpdate_NoChangeNoCallback(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateWorking, acceptTransition: true} + + callCount := 0 + callback := func(sessionID string, state AgentState) { + callCount++ + } + + config := DefaultStatusTrackerConfig() + tracker := NewStatusTracker("test-session", detector, callback, config, log) + + // First check triggers callback + tracker.CheckAndUpdate() + + // Second check with same state should not trigger callback + tracker.CheckAndUpdate() + + if callCount != 1 { + t.Errorf("Callback called %d times, want 1", callCount) + } +} + +func TestStatusTracker_CheckAndUpdate_TransitionRejected(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateWorking, acceptTransition: false} + + callCount := 0 + callback := func(sessionID string, state AgentState) { + callCount++ + } + + config := DefaultStatusTrackerConfig() + tracker := NewStatusTracker("test-session", detector, callback, config, log) + + // Check - detector rejects the transition + state := tracker.CheckAndUpdate() + + // State should remain unknown since transition was rejected + if state != StateUnknown { + t.Errorf("CheckAndUpdate() = %v, want %v (transition rejected)", state, StateUnknown) + } + + if callCount != 0 { + t.Errorf("Callback called %d times, want 0 (transition rejected)", callCount) + } +} + +func TestStatusTracker_StabilityWindow(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateWorking, acceptTransition: true} + + var lastState AgentState + callback := func(sessionID string, state AgentState) { + lastState = state + } + + config := StatusTrackerConfig{ + Rows: 24, + Cols: 80, + CheckInterval: 10 * time.Millisecond, + StabilityWindow: 50 * time.Millisecond, + } + + tracker := NewStatusTracker("test-session", detector, callback, config, log) + + // First check - state becomes pending + tracker.CheckAndUpdate() + + // State should still be unknown (waiting for stability) + if lastState == StateWorking { + t.Error("State should not change immediately with stability window") + } + + // Wait for stability window + time.Sleep(60 * time.Millisecond) + + // Now check again - state should be stable + tracker.CheckAndUpdate() + + if lastState != StateWorking { + t.Errorf("State should be %v after stability window, got %v", StateWorking, lastState) + } +} + +func TestStatusTracker_CurrentState(t *testing.T) { + log := newTestLogger(t) + detector := &mockDetector{state: StateWaitingInput, acceptTransition: true} + + config := DefaultStatusTrackerConfig() + tracker := NewStatusTracker("test-session", detector, nil, config, log) + + // Initial state + if tracker.CurrentState() != StateUnknown { + t.Errorf("CurrentState() = %v, want %v", tracker.CurrentState(), StateUnknown) + } + + // After check + tracker.CheckAndUpdate() + + if tracker.CurrentState() != StateWaitingInput { + t.Errorf("CurrentState() = %v, want %v", tracker.CurrentState(), StateWaitingInput) + } +} + +func TestDefaultStatusTrackerConfig(t *testing.T) { + config := DefaultStatusTrackerConfig() + + if config.Rows != 24 { + t.Errorf("DefaultStatusTrackerConfig().Rows = %d, want 24", config.Rows) + } + if config.Cols != 80 { + t.Errorf("DefaultStatusTrackerConfig().Cols = %d, want 80", config.Cols) + } + if config.CheckInterval != 100*time.Millisecond { + t.Errorf("DefaultStatusTrackerConfig().CheckInterval = %v, want 100ms", config.CheckInterval) + } + if config.StabilityWindow != 0 { + t.Errorf("DefaultStatusTrackerConfig().StabilityWindow = %v, want 0", config.StabilityWindow) + } +} + +func TestNoOpDetector(t *testing.T) { + detector := &NoOpDetector{} + + // Always returns unknown + state := detector.DetectState(nil, nil) + if state != StateUnknown { + t.Errorf("NoOpDetector.DetectState() = %v, want %v", state, StateUnknown) + } + + // Always accepts transitions + if !detector.ShouldAcceptStateChange(StateWorking, StateUnknown) { + t.Error("NoOpDetector.ShouldAcceptStateChange() should always return true") + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index 89cf345f61..058d0a6728 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -34,11 +34,11 @@ func setupTestRepo(t *testing.T) (string, func()) { _ = os.RemoveAll(localDir) } - // Initialize bare remote repo - runGit(t, remoteDir, "init", "--bare") + // Initialize bare remote repo with explicit branch name for consistency + runGit(t, remoteDir, "init", "--bare", "--initial-branch=main") - // Initialize local repo - runGit(t, localDir, "init") + // Initialize local repo with explicit branch name + runGit(t, localDir, "init", "--initial-branch=main") runGit(t, localDir, "config", "user.email", "test@test.com") runGit(t, localDir, "config", "user.name", "Test User") @@ -49,7 +49,7 @@ func setupTestRepo(t *testing.T) (string, func()) { // Add remote and push runGit(t, localDir, "remote", "add", "origin", remoteDir) - runGit(t, localDir, "push", "-u", "origin", "master") + runGit(t, localDir, "push", "-u", "origin", "main") return localDir, cleanup } @@ -81,7 +81,7 @@ func TestIsOnRemote(t *testing.T) { wt := NewWorkspaceTracker(repoDir, log) ctx := context.Background() - // Get the initial commit SHA (which is on origin/master) + // Get the initial commit SHA (which is on origin/main) initialSHA := runGit(t, repoDir, "rev-parse", "HEAD") initialSHA = initialSHA[:len(initialSHA)-1] // trim newline @@ -175,13 +175,13 @@ func TestGetGitStatus_AheadBehindWithoutUpstream(t *testing.T) { runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "Feature commit") - // Get git status - should still calculate ahead/behind against origin/master + // Get git status - should still calculate ahead/behind against origin/main status, err := wt.getGitStatus(ctx) if err != nil { t.Fatalf("failed to get git status: %v", err) } - // Should be 1 ahead of origin/master + // Should be 1 ahead of origin/main if status.Ahead != 1 { t.Errorf("expected ahead=1, got %d", status.Ahead) } @@ -199,8 +199,8 @@ func TestGetGitStatus_AheadBehindWithoutUpstream(t *testing.T) { // TestFilterLocalCommits_PullAndResetScenario tests the exact scenario where: // 1. Session starts at commit X -// 2. Upstream (origin/master) gets new commits -// 3. User does git fetch && git reset --hard origin/master +// 2. Upstream (origin/main) gets new commits +// 3. User does git fetch && git reset --hard origin/main // 4. The upstream commits should be filtered out func TestFilterLocalCommits_PullAndResetScenario(t *testing.T) { // Create temp directories @@ -223,18 +223,18 @@ func TestFilterLocalCommits_PullAndResetScenario(t *testing.T) { } defer func() { _ = os.RemoveAll(upstreamClone) }() - // Initialize bare remote repo - runGit(t, remoteDir, "init", "--bare") + // Initialize bare remote repo with explicit branch name + runGit(t, remoteDir, "init", "--bare", "--initial-branch=main") - // Initialize local repo (the "session" repo) - runGit(t, localDir, "init") + // Initialize local repo (the "session" repo) with explicit branch name + runGit(t, localDir, "init", "--initial-branch=main") runGit(t, localDir, "config", "user.email", "test@test.com") runGit(t, localDir, "config", "user.name", "Test User") writeFile(t, localDir, "README.md", "# Test Repo") runGit(t, localDir, "add", ".") runGit(t, localDir, "commit", "-m", "Initial commit (X)") runGit(t, localDir, "remote", "add", "origin", remoteDir) - runGit(t, localDir, "push", "-u", "origin", "master") + runGit(t, localDir, "push", "-u", "origin", "main") // Record the starting point (commit X) startingSHA := runGit(t, localDir, "rev-parse", "HEAD") @@ -260,9 +260,9 @@ func TestFilterLocalCommits_PullAndResetScenario(t *testing.T) { // Push upstream commits runGit(t, upstreamClone, "push") - // Now in the local repo (session), fetch and reset to origin/master + // Now in the local repo (session), fetch and reset to origin/main runGit(t, localDir, "fetch", "origin") - runGit(t, localDir, "reset", "--hard", "origin/master") + runGit(t, localDir, "reset", "--hard", "origin/main") // Verify HEAD is now at Z currentHead := runGit(t, localDir, "rev-parse", "HEAD") diff --git a/apps/backend/internal/agentctl/types/streams/process.go b/apps/backend/internal/agentctl/types/streams/process.go index 29b56219fe..c36001213a 100644 --- a/apps/backend/internal/agentctl/types/streams/process.go +++ b/apps/backend/internal/agentctl/types/streams/process.go @@ -6,10 +6,11 @@ import "time" type ProcessKind string const ( - ProcessKindSetup ProcessKind = "setup" - ProcessKindCleanup ProcessKind = "cleanup" - ProcessKindDev ProcessKind = "dev" - ProcessKindCustom ProcessKind = "custom" + ProcessKindSetup ProcessKind = "setup" + ProcessKindCleanup ProcessKind = "cleanup" + ProcessKindDev ProcessKind = "dev" + ProcessKindCustom ProcessKind = "custom" + ProcessKindAgentPassthrough ProcessKind = "agent_passthrough" ) // ProcessStatus represents the lifecycle status of a process. diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index f2f4a0da3e..b5fba3797c 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -92,6 +92,9 @@ const ( ProcessStatusExited = streams.ProcessStatusExited ProcessStatusFailed = streams.ProcessStatusFailed ProcessStatusStopped = streams.ProcessStatusStopped + + // Process kinds + ProcessKindAgentPassthrough = streams.ProcessKindAgentPassthrough ) // PermissionRequest represents a permission request from the agent. diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index 7ab8bc60fe..8376073a31 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -3,16 +3,18 @@ package websocket import ( "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" ) // Gateway represents the unified WebSocket gateway type Gateway struct { - Hub *Hub - Dispatcher *ws.Dispatcher - Handler *Handler - logger *logger.Logger + Hub *Hub + Dispatcher *ws.Dispatcher + Handler *Handler + TerminalHandler *TerminalHandler + logger *logger.Logger } // NewGateway creates a new WebSocket gateway with all components initialized @@ -32,8 +34,19 @@ func NewGateway(log *logger.Logger) *Gateway { } } +// SetLifecycleManager enables the dedicated terminal WebSocket handler for passthrough mode. +// This must be called before SetupRoutes if terminal passthrough is needed. +func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager) { + g.TerminalHandler = NewTerminalHandler(lifecycleMgr, g.logger) +} + // SetupRoutes adds the WebSocket routes to the Gin engine func (g *Gateway) SetupRoutes(router *gin.Engine) { router.GET("/ws", g.Handler.HandleConnection) + + // Add dedicated terminal WebSocket route if terminal handler is configured + if g.TerminalHandler != nil { + router.GET("/xterm.js/:sessionId", g.TerminalHandler.HandleTerminalWS) + } } diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go new file mode 100644 index 0000000000..5ef3d964c6 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -0,0 +1,420 @@ +// Package websocket provides WebSocket handlers for the gateway. +package websocket + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + gorillaws "github.com/gorilla/websocket" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" +) + +// TerminalHandler handles dedicated binary WebSocket connections for terminal I/O. +// This bypasses JSON encoding for raw PTY communication via xterm.js AttachAddon. +type TerminalHandler struct { + lifecycleMgr *lifecycle.Manager + logger *logger.Logger +} + +// NewTerminalHandler creates a new TerminalHandler instance. +func NewTerminalHandler(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *TerminalHandler { + return &TerminalHandler{ + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "terminal_handler")), + } +} + +// terminalUpgrader is the WebSocket upgrader for terminal connections. +// Uses larger buffers for better TUI performance. +var terminalUpgrader = gorillaws.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: checkWebSocketOrigin, +} + +// checkWebSocketOrigin validates the Origin header for WebSocket connections. +// This prevents cross-site WebSocket hijacking attacks. +func checkWebSocketOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // No origin header - allow (could be a non-browser client) + return true + } + + // Allow localhost origins for development + if strings.HasPrefix(origin, "http://localhost") || + strings.HasPrefix(origin, "http://127.0.0.1") || + strings.HasPrefix(origin, "https://localhost") || + strings.HasPrefix(origin, "https://127.0.0.1") { + return true + } + + // Check same-origin: Origin should match the Host header + host := r.Host + if host == "" { + host = r.URL.Host + } + + // Parse the origin URL to get its host + originURL, err := url.Parse(origin) + if err != nil { + return false + } + + // Compare hosts (ignoring port for flexibility) + originHost := originURL.Hostname() + requestHost := host + if colonIdx := strings.LastIndex(requestHost, ":"); colonIdx != -1 { + // Strip port from host if present (but be careful with IPv6) + if !strings.Contains(requestHost, "]") || colonIdx > strings.Index(requestHost, "]") { + requestHost = requestHost[:colonIdx] + } + } + + return originHost == requestHost +} + +// resizeCommandByte is the binary protocol marker for resize messages. +// First byte 0x01 indicates resize, followed by JSON {cols, rows}. +const resizeCommandByte = 0x01 + +// ResizePayload is the JSON payload for resize commands. +type ResizePayload struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +// wsWriter wraps a gorilla WebSocket to implement process.DirectOutputWriter. +// It writes binary frames to the WebSocket. +type wsWriter struct { + conn *gorillaws.Conn + mu sync.Mutex + closed bool +} + +func newWsWriter(conn *gorillaws.Conn) *wsWriter { + return &wsWriter{conn: conn} +} + +func (w *wsWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.closed { + return 0, io.ErrClosedPipe + } + + if err := w.conn.WriteMessage(gorillaws.BinaryMessage, p); err != nil { + return 0, err + } + return len(p), nil +} + +func (w *wsWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + w.closed = true + return nil +} + +// HandleTerminalWS handles WebSocket connections at /xterm.js/:sessionId +// This creates a binary WebSocket bridge between xterm.js and the PTY. +func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + return + } + + h.logger.Info("terminal WebSocket connection request", + zap.String("session_id", sessionID), + zap.String("remote_addr", c.Request.RemoteAddr)) + + // Validate session exists and is in passthrough mode + execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID) + if !exists { + h.logger.Warn("no execution found for session", + zap.String("session_id", sessionID)) + c.JSON(http.StatusNotFound, gin.H{"error": "no agent execution for session"}) + return + } + + if execution.PassthroughProcessID == "" { + h.logger.Warn("session is not in passthrough mode", + zap.String("session_id", sessionID)) + c.JSON(http.StatusBadRequest, gin.H{"error": "session is not in passthrough mode"}) + return + } + + // Get the interactive runner + interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() + if interactiveRunner == nil { + h.logger.Error("interactive runner not available") + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "interactive runner not available"}) + return + } + + processID := execution.PassthroughProcessID + + // Upgrade to WebSocket first - we'll get PTY access after the first resize + // triggers the lazy process start + conn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + h.logger.Error("failed to upgrade to WebSocket", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + + h.logger.Info("terminal WebSocket connected", + zap.String("session_id", sessionID), + zap.String("process_id", processID)) + + // Create WebSocket writer for output + wsw := newWsWriter(conn) + + // Run the terminal bridge + h.runTerminalBridge(conn, sessionID, processID, interactiveRunner, wsw) +} + +// runTerminalBridge handles WebSocket I/O and manages the PTY connection. +// It waits for the first resize to trigger process start, then sets up bidirectional I/O. +func (h *TerminalHandler) runTerminalBridge( + conn *gorillaws.Conn, + sessionID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, +) { + var ptyWriter io.Writer + var directOutputSet bool + var lastCols, lastRows uint16 + + // Channel to signal cleanup to any spawned goroutines + done := make(chan struct{}) + + defer func() { + // Signal any background goroutines to stop + close(done) + + // Clean up: clear direct output, close WebSocket writer, close connection + if directOutputSet { + if err := interactiveRunner.ClearDirectOutput(processID); err != nil { + h.logger.Debug("failed to clear direct output", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + _ = wsw.Close() + _ = conn.Close() + + h.logger.Info("terminal WebSocket disconnected", + zap.String("session_id", sessionID)) + }() + + // Read from WebSocket and handle input/resize + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) { + h.logger.Debug("WebSocket read error", + zap.String("session_id", sessionID), + zap.Error(err)) + } + return + } + + if messageType != gorillaws.BinaryMessage && messageType != gorillaws.TextMessage { + continue + } + + if len(data) == 0 { + continue + } + + // Check for resize command (first byte 0x01) + if data[0] == resizeCommandByte { + cols, rows := h.handleResize(data[1:], sessionID, interactiveRunner) + if cols > 0 && rows > 0 { + lastCols, lastRows = cols, rows + } + + // After resize, try to set up PTY access if not already done + if !directOutputSet { + if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) { + directOutputSet = true + + // Trigger a resize to force TUI redraw after reconnect + // This ensures the TUI app redraws with full colors/styles + // We resize to different dimensions then back to force a real redraw + // (some TUI apps ignore same-size resizes) + if lastCols > 2 && lastRows > 2 { + // Delay to ensure direct output is fully set up + time.Sleep(50 * time.Millisecond) + + // Resize to smaller dimensions to trigger a real change + // Use a more significant change (2 cols/rows) for apps that might ignore small changes + if err := interactiveRunner.ResizeBySession(sessionID, lastCols-2, lastRows-2); err != nil { + h.logger.Debug("failed to trigger intermediate resize", + zap.String("session_id", sessionID), + zap.Error(err)) + } + + // Longer delay then resize back to actual dimensions + time.Sleep(100 * time.Millisecond) + if err := interactiveRunner.ResizeBySession(sessionID, lastCols, lastRows); err != nil { + h.logger.Debug("failed to trigger final resize", + zap.String("session_id", sessionID), + zap.Error(err)) + } else { + h.logger.Info("triggered TUI redraw via resize", + zap.String("session_id", sessionID), + zap.Uint16("cols", lastCols), + zap.Uint16("rows", lastRows)) + } + + // Some TUI apps (like opencode) need a second redraw trigger + // after a longer delay to fully render + go func(sid string, cols, rows uint16, doneCh <-chan struct{}) { + select { + case <-doneCh: + return + case <-time.After(300 * time.Millisecond): + } + if err := interactiveRunner.ResizeBySession(sid, cols-1, rows); err == nil { + select { + case <-doneCh: + return + case <-time.After(50 * time.Millisecond): + } + _ = interactiveRunner.ResizeBySession(sid, cols, rows) + } + }(sessionID, lastCols, lastRows, done) + } + } + } + continue + } + + // Regular input - write to PTY if available + if ptyWriter != nil { + if _, err := ptyWriter.Write(data); err != nil { + h.logger.Debug("PTY write error", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + } else { + // PTY not ready yet - try to set it up + if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) { + directOutputSet = true + // Double-check ptyWriter is valid after setup + if ptyWriter == nil { + h.logger.Error("PTY writer is nil after successful setup", + zap.String("session_id", sessionID)) + continue + } + // Retry writing after setup + if _, err := ptyWriter.Write(data); err != nil { + h.logger.Debug("PTY write error after setup", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + } else { + h.logger.Debug("PTY not ready, dropping input", + zap.String("session_id", sessionID), + zap.Int("bytes", len(data))) + } + } + } +} + +// setupPtyAccess attempts to get PTY writer and set up direct output. +// Returns true if successful. +func (h *TerminalHandler) setupPtyAccess( + sessionID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, +) bool { + // Try to get PTY writer - this will fail if process hasn't started yet + writer, err := interactiveRunner.GetPtyWriter(processID) + if err != nil { + h.logger.Debug("PTY writer not ready yet", + zap.String("session_id", sessionID), + zap.String("process_id", processID)) + return false + } + + // Set up direct output + if err := interactiveRunner.SetDirectOutput(processID, wsw); err != nil { + h.logger.Error("failed to set direct output", + zap.String("session_id", sessionID), + zap.String("process_id", processID), + zap.Error(err)) + return false + } + + *ptyWriter = writer + + h.logger.Info("PTY access established", + zap.String("session_id", sessionID), + zap.String("process_id", processID)) + + return true +} + +// handleResize processes a resize command from the WebSocket. +// Returns the parsed dimensions (0, 0 if parsing failed). +func (h *TerminalHandler) handleResize( + data []byte, + sessionID string, + interactiveRunner *process.InteractiveRunner, +) (cols, rows uint16) { + var resize ResizePayload + if err := json.Unmarshal(data, &resize); err != nil { + h.logger.Warn("failed to parse resize command", + zap.String("session_id", sessionID), + zap.Error(err)) + return 0, 0 + } + + if resize.Cols == 0 || resize.Rows == 0 { + h.logger.Warn("invalid resize dimensions", + zap.String("session_id", sessionID), + zap.Uint16("cols", resize.Cols), + zap.Uint16("rows", resize.Rows)) + return 0, 0 + } + + // Resize triggers lazy process start if not already started + if err := interactiveRunner.ResizeBySession(sessionID, resize.Cols, resize.Rows); err != nil { + h.logger.Warn("failed to resize PTY", + zap.String("session_id", sessionID), + zap.Uint16("cols", resize.Cols), + zap.Uint16("rows", resize.Rows), + zap.Error(err)) + } else { + h.logger.Debug("PTY resized", + zap.String("session_id", sessionID), + zap.Uint16("cols", resize.Cols), + zap.Uint16("rows", resize.Rows)) + } + + // Give the process a moment to start if this was the first resize + time.Sleep(50 * time.Millisecond) + + return resize.Cols, resize.Rows +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 8cffdcb802..0313e873bb 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -70,6 +70,7 @@ type AgentProfileInfo struct { AutoApprove bool DangerouslySkipPermissions bool Plan string + CLIPassthrough bool } // LaunchAgentRequest contains parameters for launching an agent @@ -289,10 +290,12 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP "auto_approve": profileInfo.AutoApprove, "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, "plan": profileInfo.Plan, + "cli_passthrough": profileInfo.CLIPassthrough, } - e.logger.Debug("resolved agent profile for snapshot", + e.logger.Info("resolved agent profile for snapshot", zap.String("profile_id", profileInfo.ProfileID), - zap.String("model", profileInfo.Model)) + zap.String("model", profileInfo.Model), + zap.Bool("cli_passthrough", profileInfo.CLIPassthrough)) } else { // Create minimal snapshot even on failure - ensures model switching works e.logger.Warn("failed to resolve agent profile, using minimal snapshot", diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 0ff40bced7..cc755d2386 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -78,6 +78,11 @@ const ( ActionTaskSession = "task.session" ActionAgentTypes = "agent.types" + // Agent passthrough actions + ActionAgentStdin = "agent.stdin" // Send input to agent process stdin (passthrough mode) + ActionAgentStdout = "agent.stdout" // Agent stdout notification (passthrough mode) + ActionAgentResize = "agent.resize" // Resize agent PTY (passthrough mode) + // Orchestrator actions ActionOrchestratorStatus = "orchestrator.status" ActionOrchestratorQueue = "orchestrator.queue" diff --git a/apps/cli/bin/claude b/apps/cli/bin/claude new file mode 120000 index 0000000000..4e5a9bb0f4 --- /dev/null +++ b/apps/cli/bin/claude @@ -0,0 +1 @@ +../lib/node_modules/@anthropic-ai/claude-code/cli.js \ No newline at end of file diff --git a/apps/package.json b/apps/package.json index 474c79a9f2..0b521b6bf7 100644 --- a/apps/package.json +++ b/apps/package.json @@ -15,5 +15,11 @@ "devDependencies": { "@types/node": "^20", "typescript": "^5" + }, + "pnpm": { + "overrides": { + "@codemirror/state": "6.5.3", + "@codemirror/view": "6.39.10" + } } } diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 41660f8a99..7d25fdaa5c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 + importers: .: @@ -96,7 +100,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -267,10 +271,10 @@ importers: specifier: ^6.1.2 version: 6.1.2 '@codemirror/state': - specifier: ^6.5.3 + specifier: 6.5.3 version: 6.5.3 '@codemirror/view': - specifier: ^6.39.10 + specifier: 6.39.10 version: 6.39.10 '@dnd-kit/core': specifier: ^6.3.1 @@ -311,9 +315,18 @@ importers: '@uiw/react-codemirror': specifier: ^4.25.4 version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.2)(@codemirror/search@6.6.0)(@codemirror/state@6.5.3)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.10)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@xterm/addon-attach': + specifier: ^0.12.0 + version: 0.12.0 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 + '@xterm/addon-unicode11': + specifier: ^0.9.0 + version: 0.9.0 + '@xterm/addon-webgl': + specifier: ^0.19.0 + version: 0.19.0 '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 @@ -407,7 +420,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) happy-dom: specifier: ^20.1.0 version: 20.1.0 @@ -2575,8 +2588,8 @@ packages: '@codemirror/language': '>=6.0.0' '@codemirror/lint': '>=6.0.0' '@codemirror/search': '>=6.0.0' - '@codemirror/state': '>=6.0.0' - '@codemirror/view': '>=6.0.0' + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 '@uiw/codemirror-theme-vscode@4.25.4': resolution: {integrity: sha512-9ob5EtLqrXBFl8uf4eFRkXjyjfyfBRVsJdt7xbc33f+2/I29/2v2nEdU/xw40+dhloxF/h1Ry281f8wAs97MWQ==} @@ -2585,16 +2598,16 @@ packages: resolution: {integrity: sha512-2SLktItgcZC4p0+PfFusEbAHwbuAWe3bOOntCevVgHtrWGtGZX3IPv2k8IKZMgOXtAHyGKpJvT9/nspPn/uCQg==} peerDependencies: '@codemirror/language': '>=6.0.0' - '@codemirror/state': '>=6.0.0' - '@codemirror/view': '>=6.0.0' + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 '@uiw/react-codemirror@4.25.4': resolution: {integrity: sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==} peerDependencies: '@babel/runtime': '>=7.11.0' - '@codemirror/state': '>=6.0.0' + '@codemirror/state': 6.5.3 '@codemirror/theme-one-dark': '>=6.0.0' - '@codemirror/view': '>=6.0.0' + '@codemirror/view': 6.39.10 codemirror: '>=6.0.0' react: '>=17.0.0' react-dom: '>=17.0.0' @@ -2718,9 +2731,18 @@ packages: '@vue/shared@3.5.26': resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + '@xterm/addon-attach@0.12.0': + resolution: {integrity: sha512-1lxvXM4JYSm60lbFmE8WMOy2oF2ip3Ye8jWorSAmwy7x8FiC53netEJ5RguL8+FSRj79MUQsNCb2hprY2QA2ig==} + '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + '@xterm/addon-unicode11@0.9.0': + resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} + + '@xterm/addon-webgl@0.19.0': + resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} + '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} @@ -8037,8 +8059,14 @@ snapshots: '@vue/shared@3.5.26': {} + '@xterm/addon-attach@0.12.0': {} + '@xterm/addon-fit@0.11.0': {} + '@xterm/addon-unicode11@0.9.0': {} + + '@xterm/addon-webgl@0.19.0': {} + '@xterm/xterm@6.0.0': {} accepts@2.0.0: diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 829bd8b151..fcbd3ed97b 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -82,6 +82,7 @@ export async function createAgentProfileAction( auto_approve: boolean; dangerously_skip_permissions: boolean; allow_indexing: boolean; + cli_passthrough: boolean; plan: string; } ): Promise { @@ -93,7 +94,7 @@ export async function createAgentProfileAction( export async function updateAgentProfileAction( id: string, - payload: Partial> + payload: Partial> ): Promise { return fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { method: 'PATCH', @@ -118,3 +119,25 @@ export async function updateAgentProfileMcpConfigAction( body: JSON.stringify(payload), }); } + +export type CommandPreviewRequest = { + model: string; + permission_settings: Record; + cli_passthrough: boolean; +}; + +export type CommandPreviewResponse = { + supported: boolean; + command: string[]; + command_string: string; +}; + +export async function previewAgentCommandAction( + agentName: string, + payload: CommandPreviewRequest +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agent-command-preview/${agentName}`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 493f472f97..b87402cdf3 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -164,6 +164,28 @@ background-clip: padding-box; } +/* xterm.js custom scrollbar styling to match global scrollbars */ +.xterm .xterm-scrollable-element { + background: var(--background) !important; +} + +.xterm .xterm-scrollable-element > .scrollbar { + width: 10px !important; + right: 0 !important; + background: transparent !important; +} + +.xterm .xterm-scrollable-element > .scrollbar > .slider { + background: var(--scrollbar-thumb) !important; + border-radius: 999px !important; + width: 6px !important; + left: 2px !important; +} + +.xterm .xterm-scrollable-element > .scrollbar:hover > .slider { + background: var(--scrollbar-thumb-hover) !important; +} + @layer utilities { .kanban-task-pulse { animation: kanban-task-pulse 5s ease-in-out infinite; diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 5187df28ad..862db6ab9f 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -12,7 +12,7 @@ import { } from '@/lib/api'; import type { ListMessagesResponse, Task } from '@/lib/types/http'; import { snapshotToState, taskToState } from '@/lib/ssr/mapper'; -import TaskPageClient from '@/app/task/[id]/page-client'; +import { TaskPageContent } from '@/components/task/task-page-content'; export default async function SessionPage({ params, @@ -155,7 +155,7 @@ export default async function SessionPage({ return ( <> {initialState ? : null} - ; + cliPassthrough: boolean; +}; + +export function CommandPreviewCard({ + agentName, + model, + permissionSettings, + cliPassthrough, +}: CommandPreviewCardProps) { + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + // Stable key for debouncing - changes when settings change + const settingsKey = useMemo(() => + JSON.stringify({ model, permissionSettings, cliPassthrough }), + [model, permissionSettings, cliPassthrough] + ); + + useEffect(() => { + setLoading(true); + setError(null); + + const timeoutId = setTimeout(async () => { + try { + const response = await previewAgentCommandAction(agentName, { + model, + permission_settings: permissionSettings, + cli_passthrough: cliPassthrough, + }); + setPreview(response); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load command preview'); + setPreview(null); + } finally { + setLoading(false); + } + }, 300); // 300ms debounce + + return () => clearTimeout(timeoutId); + // eslint-disable-next-line react-hooks/exhaustive-deps -- settingsKey already includes model, permissionSettings, cliPassthrough + }, [agentName, settingsKey]); + + const handleCopy = async () => { + if (!preview?.command_string) return; + + try { + await navigator.clipboard.writeText(preview.command_string); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = preview.command_string; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + + + + Command Preview + + + +

+ The CLI command that will be executed based on the current settings. +

+ + {loading && ( +
+
+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!loading && !error && preview && ( + <> +
+
+                {preview.command_string}
+              
+ +
+ +

+ {'{prompt}'} will be replaced with your task description or follow-up message. +

+ + )} + + {!loading && !error && !preview && ( +
+

No command preview available.

+
+ )} + + + ); +} diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts index 2502ae262c..9de1ed4b71 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts @@ -3,13 +3,14 @@ import { useMemo } from 'react'; import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; import { useAppStore } from '@/components/state-provider'; -import type { Agent, AgentProfile, ModelConfig, AvailableAgent, PermissionSetting } from '@/lib/types/http'; +import type { Agent, AgentProfile, ModelConfig, AvailableAgent, PermissionSetting, PassthroughConfig } from '@/lib/types/http'; type AgentProfileSettingsResult = { agent: Agent | null; profile: AgentProfile | null; modelConfig: ModelConfig; permissionSettings: Record; + passthroughConfig: PassthroughConfig | null; }; export function useAgentProfileSettings( @@ -39,5 +40,9 @@ export function useAgentProfileSettings( return availableAgent?.permission_settings ?? {}; }, [availableAgent]); - return { agent, profile, modelConfig, permissionSettings }; + const passthroughConfig = useMemo(() => { + return availableAgent?.passthrough_config ?? null; + }, [availableAgent]); + + return { agent, profile, modelConfig, permissionSettings, passthroughConfig }; } diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 24d105da74..e8684df71e 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -6,19 +6,21 @@ import { useParams } from 'next/navigation'; import { IconTrash } from '@tabler/icons-react'; import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; -import { Input } from '@kandev/ui/input'; import { Label } from '@kandev/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; import { Separator } from '@kandev/ui/separator'; import { Switch } from '@kandev/ui/switch'; import { Textarea } from '@kandev/ui/textarea'; +import { Input } from '@kandev/ui/input'; import { useToast } from '@/components/toast-provider'; import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; +import { ModelCombobox } from '@/components/settings/model-combobox'; import { deleteAgentProfileAction, updateAgentProfileAction } from '@/app/actions/agents'; -import type { Agent, AgentProfile, ModelConfig, PermissionSetting } from '@/lib/types/http'; +import type { Agent, AgentProfile, ModelConfig, PermissionSetting, PassthroughConfig } from '@/lib/types/http'; import { useAppStore } from '@/components/state-provider'; import { ProfileMcpConfigCard } from '@/app/settings/agents/[agentId]/profile-mcp-config-card'; +import { CommandPreviewCard } from '@/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card'; import type { AgentProfileMcpConfig } from '@/lib/types/http'; import { useAgentProfileSettings } from '@/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings'; @@ -27,10 +29,11 @@ type ProfileEditorProps = { profile: AgentProfile; modelConfig: ModelConfig; permissionSettings: Record; + passthroughConfig: PassthroughConfig | null; initialMcpConfig?: AgentProfileMcpConfig | null; }; -function ProfileEditor({ agent, profile, modelConfig, permissionSettings, initialMcpConfig }: ProfileEditorProps) { +function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passthroughConfig, initialMcpConfig }: ProfileEditorProps) { const { toast } = useToast(); const settingsAgents = useAppStore((state) => state.settingsAgents.items); const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); @@ -58,6 +61,7 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, initia draft.auto_approve !== savedProfile.auto_approve || draft.dangerously_skip_permissions !== savedProfile.dangerously_skip_permissions || draft.allow_indexing !== savedProfile.allow_indexing || + draft.cli_passthrough !== savedProfile.cli_passthrough || draft.plan !== savedProfile.plan ); }, [draft, savedProfile]); @@ -87,6 +91,7 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, initia auto_approve: draft.auto_approve, dangerously_skip_permissions: draft.dangerously_skip_permissions, allow_indexing: draft.allow_indexing, + cli_passthrough: draft.cli_passthrough, plan: draft.plan, }); setSavedProfile(updated); @@ -176,32 +181,13 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, initia
- {modelConfig.available_models.length > 0 ? ( - - ) : ( - setDraft({ ...draft, model: event.target.value })} - placeholder="model-id" - /> - )} + setDraft({ ...draft, model: value })} + models={modelConfig.available_models} + defaultModel={modelConfig.default_model} + placeholder="Select or enter model..." + />
@@ -264,10 +250,50 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, initia />
)} + + {passthroughConfig?.supported && ( +
+
+
+ + + + + Experimental + + + +

Currently does not support notifications and session resume

+
+
+
+

+ {passthroughConfig.description} +

+
+ + setDraft({ ...draft, cli_passthrough: checked }) + } + /> +
+ )}
+ + + Delete profile @@ -310,7 +337,7 @@ export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientPro const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; const agentKey = decodeURIComponent(agentParam ?? ''); const profileId = profileParam ?? ''; - const { agent, profile, modelConfig, permissionSettings } = useAgentProfileSettings(agentKey, profileId); + const { agent, profile, modelConfig, permissionSettings, passthroughConfig } = useAgentProfileSettings(agentKey, profileId); if (!agent || !profile) { return ( @@ -332,6 +359,7 @@ export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientPro profile={profile} modelConfig={modelConfig} permissionSettings={permissionSettings} + passthroughConfig={passthroughConfig} initialMcpConfig={initialMcpConfig} /> ); diff --git a/apps/web/components/settings/model-combobox.tsx b/apps/web/components/settings/model-combobox.tsx new file mode 100644 index 0000000000..eee217b06d --- /dev/null +++ b/apps/web/components/settings/model-combobox.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useState } from 'react'; +import { Input } from '@kandev/ui/input'; +import { Label } from '@kandev/ui/label'; +import { Switch } from '@kandev/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; +import type { ModelEntry } from '@/lib/types/http'; + +type ModelComboboxProps = { + value: string; + onChange: (value: string) => void; + models: ModelEntry[]; + defaultModel?: string; + placeholder?: string; +}; + +export function ModelCombobox({ + value, + onChange, + models, + defaultModel, + placeholder = 'Select a model', +}: ModelComboboxProps) { + // Determine if the current value is a custom model (not in the list) + const isValueCustom = Boolean(value) && !models.some((m) => m.id === value); + // Track user's explicit toggle choice; null means derive from value + const [userToggle, setUserToggle] = useState(null); + // Derive custom mode: user toggle takes precedence, otherwise infer from value + const isCustomMode = userToggle ?? isValueCustom; + + const handleModeChange = (custom: boolean) => { + setUserToggle(custom); + if (!custom && isValueCustom) { + // Switching to select mode with a custom value - reset to default + onChange(defaultModel || ''); + } + }; + + return ( +
+ {isCustomMode ? ( + onChange(e.target.value)} + placeholder="Enter model ID (e.g., gpt-4o)" + /> + ) : ( + + )} + +
+ + +
+
+ ); +} diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx new file mode 100644 index 0000000000..1557d9dabd --- /dev/null +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -0,0 +1,427 @@ +'use client'; + +import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { AttachAddon } from '@xterm/addon-attach'; +import { Unicode11Addon } from '@xterm/addon-unicode11'; +import { WebglAddon } from '@xterm/addon-webgl'; +import '@xterm/xterm/css/xterm.css'; +import { useAppStore } from '@/components/state-provider'; +import { useSession } from '@/hooks/domains/session/use-session'; +import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl'; +import { getBackendConfig } from '@/lib/config'; +import { terminalTheme, applyTransparentBackground } from '@/lib/terminal-theme'; + +type PassthroughTerminalProps = { + sessionId?: string | null; +}; + +// Debug flag - set to true to see detailed logs +const DEBUG = false; +const log = (...args: unknown[]) => { + if (DEBUG) console.log('[PassthroughTerminal]', ...args); +}; + +// Minimum dimensions to prevent zero-size issues +const MIN_WIDTH = 100; +const MIN_HEIGHT = 100; + +/** + * PassthroughTerminal provides direct terminal interaction with an agent CLI. + * + * Design: Dedicated Binary WebSocket + AttachAddon + * - Uses a dedicated WebSocket connection to /xterm.js/:sessionId + * - Raw binary frames bypass JSON encoding/decoding latency + * - AttachAddon (official xterm.js addon) handles the bridging + * - Unicode11Addon enables proper unicode character support + * - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}] + */ +export function PassthroughTerminal({ sessionId: propSessionId }: PassthroughTerminalProps) { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + const attachAddonRef = useRef(null); + const isInitializedRef = useRef(false); + const lastDimensionsRef = useRef({ cols: 0, rows: 0 }); + const resizeTimeoutRef = useRef | null>(null); + const webglAddonRef = useRef(null); + + // Get sessionId from store if not provided as prop + const storeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const sessionId = propSessionId ?? storeSessionId; + + const { session, isActive } = useSession(sessionId); + useSessionAgentctl(sessionId); + const taskId = session?.task_id ?? null; + + const canConnect = Boolean(sessionId && isActive); + + // Compute WebSocket base URL from backend config + const wsBaseUrl = useMemo(() => { + try { + const backendUrl = getBackendConfig().apiBaseUrl; + const url = new URL(backendUrl); + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${url.host}`; + } catch { + return 'ws://localhost:8080'; + } + }, []); + + // Send resize command via binary protocol + const sendResize = useCallback((cols: number, rows: number) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + log('sendResize: WebSocket not ready', ws?.readyState); + return; + } + if (cols <= 0 || rows <= 0) { + log('sendResize: invalid dimensions', cols, rows); + return; + } + + log('sendResize:', cols, 'x', rows); + + // Binary protocol: first byte 0x01 = resize command, followed by JSON + const json = JSON.stringify({ cols, rows }); + const encoder = new TextEncoder(); + const jsonBytes = encoder.encode(json); + const buffer = new Uint8Array(1 + jsonBytes.length); + buffer[0] = 0x01; // Resize command byte + buffer.set(jsonBytes, 1); + ws.send(buffer); + }, []); + + // Fit terminal and send resize if dimensions changed + const fitAndResize = useCallback((force = false) => { + const terminal = xtermRef.current; + const fitAddon = fitAddonRef.current; + const container = terminalRef.current; + + if (!terminal || !fitAddon || !container) { + log('fitAndResize: missing refs'); + return; + } + + const rect = container.getBoundingClientRect(); + log('fitAndResize: container', rect.width, 'x', rect.height); + + // Skip if container is too small + if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { + log('fitAndResize: container too small, skipping'); + return; + } + + try { + fitAddon.fit(); + log('fitAndResize: fit done', terminal.cols, 'x', terminal.rows); + } catch (e) { + log('fitAndResize: fit failed', e); + return; + } + + const { cols, rows } = terminal; + const last = lastDimensionsRef.current; + + // Only send resize if dimensions changed or force is true + if (force || cols !== last.cols || rows !== last.rows) { + lastDimensionsRef.current = { cols, rows }; + sendResize(cols, rows); + } + }, [sendResize]); + + // Initialize terminal - runs once per mount + useEffect(() => { + log('Terminal init effect'); + + const container = terminalRef.current; + if (!container) { + log('No container ref'); + return; + } + + if (isInitializedRef.current) { + log('Already initialized'); + return; + } + + // Wait for container to have valid dimensions + const initWhenReady = () => { + const rect = container.getBoundingClientRect(); + log('Init check: dimensions', rect.width, 'x', rect.height); + + if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) { + initializeTerminal(); + return true; + } + return false; + }; + + // Try immediately, then retry with RAF + if (!initWhenReady()) { + let retryCount = 0; + const maxRetries = 30; + + const retry = () => { + if (isInitializedRef.current) return; + retryCount++; + if (initWhenReady() || retryCount >= maxRetries) { + if (retryCount >= maxRetries) { + log('Max retries, forcing init'); + initializeTerminal(); + } + return; + } + requestAnimationFrame(retry); + }; + + requestAnimationFrame(retry); + } + + function initializeTerminal() { + if (isInitializedRef.current || xtermRef.current || !container) { + return; + } + isInitializedRef.current = true; + + // Capture container reference for closures + const termContainer = container; + + log('Creating terminal'); + + const terminal = new Terminal({ + allowProposedApi: true, + cursorBlink: true, + disableStdin: false, + convertEol: false, + scrollOnUserInput: true, + scrollback: 5000, + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: terminalTheme, + }); + + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + // Load Unicode11 addon + const unicode11Addon = new Unicode11Addon(); + terminal.loadAddon(unicode11Addon); + terminal.unicode.activeVersion = '11'; + + log('Opening terminal in container'); + terminal.open(termContainer); + + // Initial fit + try { + fitAddon.fit(); + lastDimensionsRef.current = { cols: terminal.cols, rows: terminal.rows }; + log('Initial fit:', terminal.cols, 'x', terminal.rows); + } catch (e) { + log('Initial fit failed:', e); + } + + // Load WebGL addon + try { + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + log('WebGL context lost'); + webglAddon.dispose(); + webglAddonRef.current = null; + }); + terminal.loadAddon(webglAddon); + webglAddonRef.current = webglAddon; + log('WebGL addon loaded'); + } catch (e) { + log('WebGL failed, using canvas:', e); + } + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + + // Apply transparent background styling + const applyStyles = () => applyTransparentBackground(termContainer); + requestAnimationFrame(applyStyles); + + // Debounced resize handler + const handleResize = () => { + const rect = termContainer.getBoundingClientRect(); + log('ResizeObserver:', rect.width, 'x', rect.height); + + // Skip zero/tiny dimensions + if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { + log('Skipping resize - too small'); + return; + } + + // Clear pending resize + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + + // Debounce the actual fit/resize + resizeTimeoutRef.current = setTimeout(() => { + fitAndResize(); + // Re-apply styles after resize + applyStyles(); + }, 100); + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(termContainer); + + // Store cleanup + return () => { + log('Terminal cleanup'); + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeObserver.disconnect(); + if (webglAddonRef.current) { + webglAddonRef.current.dispose(); + webglAddonRef.current = null; + } + terminal.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; + isInitializedRef.current = false; + lastDimensionsRef.current = { cols: 0, rows: 0 }; + }; + } + + return () => { + log('Effect cleanup'); + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + if (webglAddonRef.current) { + webglAddonRef.current.dispose(); + webglAddonRef.current = null; + } + if (xtermRef.current) { + xtermRef.current.dispose(); + xtermRef.current = null; + } + fitAddonRef.current = null; + isInitializedRef.current = false; + lastDimensionsRef.current = { cols: 0, rows: 0 }; + }; + }, [fitAndResize]); + + // Connect WebSocket when ready + useEffect(() => { + log('WebSocket effect:', { taskId, sessionId, canConnect, hasTerminal: !!xtermRef.current }); + + if (!taskId || !sessionId || !canConnect) { + return; + } + if (!xtermRef.current || !fitAddonRef.current) { + log('Terminal not ready for WebSocket'); + return; + } + + const terminal = xtermRef.current; + let isMounted = true; + let connectTimeout: ReturnType | null = null; + let refreshInterval: ReturnType | null = null; + + // Delay connection to handle React Strict Mode + connectTimeout = setTimeout(() => { + if (!isMounted) return; + + // Close existing connection + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + const wsUrl = `${wsBaseUrl}/xterm.js/${sessionId}`; + log('Connecting to', wsUrl); + + const ws = new WebSocket(wsUrl); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + + ws.onopen = () => { + if (!isMounted) { + ws.close(); + return; + } + log('WebSocket connected'); + + // Attach to terminal + const attachAddon = new AttachAddon(ws, { bidirectional: true }); + terminal.loadAddon(attachAddon); + attachAddonRef.current = attachAddon; + + // Send initial resize to trigger PTY and get content + // Send multiple times with delay to ensure PTY redraws + const triggerRefresh = () => { + if (isMounted && ws.readyState === WebSocket.OPEN) { + fitAndResize(true); + } + }; + + // Immediate refresh + requestAnimationFrame(triggerRefresh); + + // Follow-up refreshes to ensure content appears + setTimeout(triggerRefresh, 100); + setTimeout(triggerRefresh, 300); + setTimeout(triggerRefresh, 500); + + // Also set up periodic refresh check for the first few seconds + let refreshCount = 0; + refreshInterval = setInterval(() => { + refreshCount++; + if (refreshCount > 5 || !isMounted) { + if (refreshInterval) clearInterval(refreshInterval); + return; + } + triggerRefresh(); + }, 1000); + }; + + ws.onclose = (event) => { + log('WebSocket closed:', event.code, event.reason); + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + }; + + ws.onerror = (error) => { + log('WebSocket error:', error); + }; + }, 150); + + return () => { + log('WebSocket cleanup'); + isMounted = false; + if (connectTimeout) clearTimeout(connectTimeout); + if (refreshInterval) clearInterval(refreshInterval); + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl]); + + return ( +
+ ); +} diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 63f542a97c..c5af0ab689 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -8,6 +8,7 @@ import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useSession } from '@/hooks/domains/session/use-session'; import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl'; +import { terminalTheme, applyTransparentBackground } from '@/lib/terminal-theme'; type ShellTerminalProps = { // Interactive shell mode - requires sessionId @@ -79,29 +80,7 @@ export function ShellTerminal({ convertEol: isReadOnlyMode, fontSize: isReadOnlyMode ? 12 : 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { - background: 'transparent', - foreground: '#d4d4d4', - cursor: '#d4d4d4', - cursorAccent: 'transparent', - selectionBackground: '#264f78', - black: '#1e1e1e', - red: '#f44747', - green: '#6a9955', - yellow: '#dcdcaa', - blue: '#569cd6', - magenta: '#c586c0', - cyan: '#4ec9b0', - white: '#d4d4d4', - brightBlack: '#808080', - brightRed: '#f44747', - brightGreen: '#6a9955', - brightYellow: '#dcdcaa', - brightBlue: '#569cd6', - brightMagenta: '#c586c0', - brightCyan: '#4ec9b0', - brightWhite: '#ffffff', - }, + theme: terminalTheme, }); const fitAddon = new FitAddon(); @@ -113,14 +92,7 @@ export function ShellTerminal({ fitAddonRef.current = fitAddon; // Force transparent background on all xterm elements to inherit from parent - const xtermElement = terminalRef.current.querySelector('.xterm'); - const xtermViewport = terminalRef.current.querySelector('.xterm-viewport'); - const xtermScreen = terminalRef.current.querySelector('.xterm-screen'); - const xtermScrollable = terminalRef.current.querySelector('.xterm-scrollable-element'); - if (xtermElement) (xtermElement as HTMLElement).style.background = 'transparent'; - if (xtermViewport) (xtermViewport as HTMLElement).style.background = 'transparent'; - if (xtermScreen) (xtermScreen as HTMLElement).style.background = 'transparent'; - if (xtermScrollable) (xtermScrollable as HTMLElement).style.backgroundColor = 'transparent'; + applyTransparentBackground(terminalRef.current); // For read-only mode, write initial output if (isReadOnlyMode && outputRef.current) { diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 3a1ca472bf..c8756a03bd 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -7,6 +7,7 @@ import { SessionPanel } from '@kandev/ui/pannel-session'; import { TaskChatPanel } from './task-chat-panel'; import { TaskChangesPanel } from './task-changes-panel'; import { FileViewerContent } from './file-viewer-content'; +import { PassthroughTerminal } from './passthrough-terminal'; import type { OpenFileTab } from '@/lib/types/backend'; import { FILE_EXTENSION_COLORS } from '@/lib/types/backend'; import { useAppStore } from '@/components/state-provider'; @@ -30,6 +31,18 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ sessionId = null, }: TaskCenterPanelProps) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const activeSession = useAppStore((state) => + activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null + ); + + // Check if session is in passthrough mode by looking at the profile snapshot + const isPassthroughMode = useMemo(() => { + if (!activeSession?.agent_profile_snapshot) return false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const snapshot = activeSession.agent_profile_snapshot as any; + return snapshot?.cli_passthrough === true; + }, [activeSession?.agent_profile_snapshot]); const [leftTab, setLeftTab] = useState('chat'); const [selectedDiff, setSelectedDiff] = useState(null); const [notes, setNotes] = useState(''); @@ -126,9 +139,15 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ /> - + {activeTaskId ? ( - + isPassthroughMode ? ( +
+ +
+ ) : ( + + ) ) : (
No task selected diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/components/task/task-page-content.tsx similarity index 89% rename from apps/web/app/task/[id]/page-client.tsx rename to apps/web/components/task/task-page-content.tsx index 126b71354c..3d6b45e743 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -18,19 +18,25 @@ import { fetchTask } from '@/lib/api'; import { useTasks } from '@/hooks/use-tasks'; import type { Layout } from 'react-resizable-panels'; -type TaskPageClientProps = { +type TaskPageContentProps = { task: Task | null; sessionId?: string | null; initialRepositories?: Repository[]; defaultLayouts?: Record; }; -export default function TaskPage({ +/** + * TaskPageContent is the main content component for the task/session page. + * It handles task state, agent management, and renders the task layout. + * + * This is a shared component used by the session page (/s/[sessionId]). + */ +export function TaskPageContent({ task: initialTask, sessionId = null, initialRepositories = [], defaultLayouts = {}, -}: TaskPageClientProps) { +}: TaskPageContentProps) { const [taskDetails, setTaskDetails] = useState(initialTask); const [isMounted, setIsMounted] = useState(false); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); @@ -122,7 +128,7 @@ export default function TaskPage({ }, []); useEffect(() => { - console.log('[TaskPage] Active session setup effect', { + console.log('[TaskPageContent] Active session setup effect', { initialTaskId: initialTask?.id, activeTaskId, initialSessionId, @@ -130,21 +136,19 @@ export default function TaskPage({ taskSessionId, }); if (!initialTask?.id) return; - if (activeTaskId && activeTaskId !== initialTask.id) { - console.log('[TaskPage] Skipping - different activeTaskId already set'); - return; - } + // Always sync active task/session when SSR provides data. + // This ensures navigation to a new task/session updates the store. if (initialSessionId) { - console.log('[TaskPage] Setting active session', initialTask.id, initialSessionId); + console.log('[TaskPageContent] Setting active session', initialTask.id, initialSessionId); setActiveSession(initialTask.id, initialSessionId); - } else { - console.log('[TaskPage] Setting active task', initialTask.id); + } else if (activeTaskId !== initialTask.id) { + console.log('[TaskPageContent] Setting active task', initialTask.id); setActiveTask(initialTask.id); } }, [activeTaskId, initialSessionId, initialTask?.id, sessionId, taskSessionId, setActiveSession, setActiveTask]); useEffect(() => { - console.log('[TaskPage] Task fetch effect', { + console.log('[TaskPageContent] Task fetch effect', { activeTaskId, initialTaskId: initialTask?.id, taskDetailsId: taskDetails?.id, @@ -152,13 +156,13 @@ export default function TaskPage({ }); if (!activeTaskId) return; if (taskDetails?.id === activeTaskId) return; - console.log('[TaskPage] Fetching task details for', activeTaskId); + console.log('[TaskPageContent] Fetching task details for', activeTaskId); fetchTask(activeTaskId, { cache: 'no-store' }) .then((response) => { - console.log('[TaskPage] Fetched task details', response?.id); + console.log('[TaskPageContent] Fetched task details', response?.id); setTaskDetails(response); }) - .catch((error) => console.error('[TaskPage] Failed to load task details:', error)); + .catch((error) => console.error('[TaskPageContent] Failed to load task details:', error)); }, [activeTaskId, initialTask?.id, taskDetails?.id]); const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); const effectiveRepositories = repositories.length ? repositories : initialRepositories; diff --git a/apps/web/hooks/use-task-sessions.ts b/apps/web/hooks/use-task-sessions.ts index 26b481056e..c756845968 100644 --- a/apps/web/hooks/use-task-sessions.ts +++ b/apps/web/hooks/use-task-sessions.ts @@ -24,6 +24,12 @@ export function useTaskSessions(taskId: string | null) { setTaskSessionsLoading(taskId, true); try { const response = await listTaskSessions(taskId, { cache: 'no-store' }); + // Debug: log the fetched sessions to see if they have agent_profile_snapshot + console.log('[useTaskSessions] fetched sessions:', response.sessions?.map(s => ({ + id: s.id, + hasSnapshot: !!s.agent_profile_snapshot, + cli_passthrough: s.agent_profile_snapshot?.cli_passthrough, + }))); setTaskSessionsForTask(taskId, response.sessions ?? []); } catch { setTaskSessionsForTask(taskId, []); diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 11fecf1628..e419e07257 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -71,6 +71,29 @@ export async function getAgentProfileMcpConfig( return fetchJson(`/api/v1/agent-profiles/${profileId}/mcp-config`, options); } +export type CommandPreviewRequest = { + model: string; + permission_settings: Record; + cli_passthrough: boolean; +}; + +export type CommandPreviewResponse = { + supported: boolean; + command: string[]; + command_string: string; +}; + +export async function previewAgentCommand( + agentName: string, + payload: CommandPreviewRequest, + options?: ApiRequestOptions +): Promise { + return fetchJson(`/api/v1/agent-command-preview/${agentName}`, { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + // Editors export async function listEditors(options?: ApiRequestOptions) { return fetchJson('/api/v1/editors', options); diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index b67c1df310..d1ebb54585 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -140,11 +140,17 @@ export const createSessionSlice: StateCreator< }), setTaskSession: (session) => set((draft) => { - // Merge with existing session data to preserve fields like agent_profile_id + // Merge with existing session data to preserve fields like agent_profile_id and agent_profile_snapshot const existingSession = draft.taskSessions.items[session.id]; - draft.taskSessions.items[session.id] = existingSession - ? { ...existingSession, ...session } - : session; + if (existingSession) { + draft.taskSessions.items[session.id] = { + ...existingSession, + ...session, + agent_profile_snapshot: session.agent_profile_snapshot ?? existingSession.agent_profile_snapshot, + }; + } else { + draft.taskSessions.items[session.id] = session; + } }), setTaskSessionsForTask: (taskId, sessions) => set((draft) => { @@ -158,6 +164,22 @@ export const createSessionSlice: StateCreator< draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions; draft.taskSessionsByTask.loadingByTaskId[taskId] = false; draft.taskSessionsByTask.loadedByTaskId[taskId] = true; + // Also populate taskSessions.items for individual session lookups + // This ensures agent_profile_snapshot is available when accessed by session ID + for (const session of sessions) { + const existing = draft.taskSessions.items[session.id]; + if (existing) { + // Merge new data with existing, preserving agent_profile_snapshot if not provided + draft.taskSessions.items[session.id] = { + ...existing, + ...session, + // Preserve existing snapshot if new session doesn't have one + agent_profile_snapshot: session.agent_profile_snapshot ?? existing.agent_profile_snapshot, + }; + } else { + draft.taskSessions.items[session.id] = session; + } + } }), setTaskSessionsLoading: (taskId, loading) => set((draft) => { diff --git a/apps/web/lib/terminal-theme.ts b/apps/web/lib/terminal-theme.ts new file mode 100644 index 0000000000..ae99f8ff06 --- /dev/null +++ b/apps/web/lib/terminal-theme.ts @@ -0,0 +1,44 @@ +import type { ITheme } from '@xterm/xterm'; + +/** + * Shared xterm.js theme configuration for all terminal components. + * Uses transparent background so terminals inherit from their container's bg-background. + */ +export const terminalTheme: ITheme = { + background: 'transparent', + foreground: '#d4d4d4', + cursor: '#d4d4d4', + cursorAccent: 'transparent', + selectionBackground: '#264f78', + black: '#1e1e1e', + red: '#f44747', + green: '#6a9955', + yellow: '#dcdcaa', + blue: '#569cd6', + magenta: '#c586c0', + cyan: '#4ec9b0', + white: '#d4d4d4', + brightBlack: '#808080', + brightRed: '#f44747', + brightGreen: '#6a9955', + brightYellow: '#dcdcaa', + brightBlue: '#569cd6', + brightMagenta: '#c586c0', + brightCyan: '#4ec9b0', + brightWhite: '#ffffff', +}; + +/** + * Apply transparent background to xterm.js internal elements. + * This ensures the terminal inherits the container's background color. + */ +export function applyTransparentBackground(container: HTMLElement): void { + const selectors = ['.xterm', '.xterm-viewport', '.xterm-screen', '.xterm-scrollable-element']; + selectors.forEach((selector) => { + const el = container.querySelector(selector) as HTMLElement | null; + if (el) { + el.style.background = 'transparent'; + el.style.backgroundColor = 'transparent'; + } + }); +} diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 35a6d292c6..320531419c 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -243,6 +243,7 @@ export type AgentProfilePayload = { auto_approve: boolean; dangerously_skip_permissions: boolean; allow_indexing: boolean; + cli_passthrough?: boolean; plan: string; created_at?: string; updated_at?: string; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index aaf0a1c4fd..bbd98c7df7 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -395,6 +395,7 @@ export type AgentProfile = { auto_approve: boolean; dangerously_skip_permissions: boolean; allow_indexing: boolean; + cli_passthrough: boolean; plan: string; created_at: string; updated_at: string; @@ -471,6 +472,12 @@ export type PermissionSetting = { cli_flag_value?: string; }; +export type PassthroughConfig = { + supported: boolean; + label: string; + description: string; +}; + export type AvailableAgent = { name: string; display_name: string; @@ -482,6 +489,7 @@ export type AvailableAgent = { capabilities: AgentCapabilities; model_config: ModelConfig; permission_settings?: Record; + passthrough_config?: PassthroughConfig; updated_at: string; }; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 77385df29b..74a6722ce6 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -18,14 +18,13 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle if (sessionId) { const existingSession = store.getState().taskSessions.items[sessionId]; const agentProfileId = payload.agent_profile_id; - const agentProfileSnapshot = payload.agent_profile_snapshot; + const agentProfileSnapshot = payload.agent_profile_snapshot as Record | undefined; if (existingSession) { // Update existing session - preserve all existing fields, but update snapshot if provided store.getState().setTaskSession({ ...existingSession, state: newState, - // Update snapshot if provided in the event ...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}), }); } else { @@ -57,11 +56,9 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle }]); } else { const nextSessions = sessionsByTask.map((session) => - session.id === sessionId ? { - ...session, - state: newState, - ...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}), - } : session + session.id === sessionId + ? { ...session, state: newState, ...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}) } + : session ); store.getState().setTaskSessionsForTask(taskId, nextSessions); } diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts index 3fd5d64285..591f4c09d7 100644 --- a/apps/web/lib/ws/handlers/agents.ts +++ b/apps/web/lib/ws/handlers/agents.ts @@ -48,6 +48,7 @@ export function registerAgentsHandlers(store: StoreApi): WsHandlers { auto_approve: message.payload.profile.auto_approve, dangerously_skip_permissions: message.payload.profile.dangerously_skip_permissions, allow_indexing: message.payload.profile.allow_indexing, + cli_passthrough: message.payload.profile.cli_passthrough ?? false, plan: message.payload.profile.plan, created_at: message.payload.profile.created_at ?? '', updated_at: message.payload.profile.updated_at ?? '', diff --git a/apps/web/lib/ws/handlers/terminals.ts b/apps/web/lib/ws/handlers/terminals.ts index 692c766c20..af78fd8025 100644 --- a/apps/web/lib/ws/handlers/terminals.ts +++ b/apps/web/lib/ws/handlers/terminals.ts @@ -20,11 +20,15 @@ export function registerTerminalsHandlers(store: StoreApi): WsHandlers } }, 'session.process.output': (message) => { - const { process_id, data } = message.payload; + const { process_id, session_id, kind, data } = message.payload; if (!process_id || !data) { return; } store.getState().appendProcessOutput(process_id, data); + // For passthrough mode, also store output under session_id for the PassthroughTerminal + if (kind === 'agent_passthrough' && session_id) { + store.getState().appendShellOutput(`passthrough:${session_id}`, data); + } }, 'session.process.status': (message) => { const { diff --git a/apps/web/package.json b/apps/web/package.json index d757158828..c1a4c6bce2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,7 +39,10 @@ "@tanstack/react-virtual": "^3.13.18", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", + "@xterm/addon-attach": "^0.12.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",