Files
kandev/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go
T
2026-07-03 15:39:06 +01:00

1506 lines
59 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lifecycle
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/settings/cliflags"
"github.com/kandev/kandev/internal/agentctl/server/process"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/events"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// MarkPassthroughRunning marks a passthrough execution as running when user submits input.
// This is called when Enter key is detected in the terminal handler.
// It updates the execution status and publishes an AgentRunning event.
func (m *Manager) MarkPassthroughRunning(sessionID 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)
}
// Only publish if not already running (prevents duplicate events)
if execution.Status != v1.AgentStatusRunning {
if err := m.UpdateStatus(execution.ID, v1.AgentStatusRunning); err != nil {
return err
}
m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentRunning, execution)
}
return nil
}
// 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.
// Note: For terminal handler input, use MarkPassthroughRunning directly since
// the terminal handler writes to PTY directly for performance.
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")
}
// Write to stdin
if err := interactiveRunner.WriteStdin(execution.PassthroughProcessID, data); err != nil {
return err
}
return nil
}
// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode.
func (m *Manager) IsPassthroughSession(ctx context.Context, sessionID string) bool {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
return false
}
return execution.PassthroughProcessID != ""
}
// 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
}
// buildPassthroughEnv builds the environment map for a passthrough session,
// including Kandev metadata and required credentials from the agent runtime config.
func (m *Manager) buildPassthroughEnv(ctx context.Context, execution *AgentExecution, requiredEnv []string) map[string]string {
env := make(map[string]string)
env["KANDEV_TASK_ID"] = execution.TaskID
env["KANDEV_SESSION_ID"] = execution.SessionID
env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID
m.mergeAgentProfileEnv(ctx, execution.AgentProfileID, env)
if m.credsMgr != nil {
for _, credKey := range requiredEnv {
if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" {
env[credKey] = value
}
}
}
// Merge env vars contributed by the passthrough MCP strategy (e.g. opencode's
// OPENCODE_CONFIG). Set during command building in applyPassthroughMCP.
for key, value := range getPassthroughMCPEnv(execution) {
env[key] = value
}
return env
}
// startPassthroughShell starts the shell session for a passthrough execution.
// Non-fatal errors are logged with the provided warning message.
func (m *Manager) startPassthroughShell(ctx context.Context, execution *AgentExecution, shellWarnMsg string) {
if execution.agentctl == nil {
return
}
if err := execution.agentctl.StartShell(ctx); err != nil {
m.logger.Warn(shellWarnMsg,
zap.String("execution_id", execution.ID),
zap.Error(err))
} else {
m.logger.Info("shell session started for passthrough mode",
zap.String("execution_id", execution.ID))
}
}
// resolvedPassthrough holds the agent config, passthrough config, runtime config, and profile
// info resolved from an execution. Used as the basis for building passthrough commands.
type resolvedPassthrough struct {
agentID string
agentConfig agents.Agent
agent agents.PassthroughAgent
pt agents.PassthroughConfig
rt *agents.RuntimeConfig
profile *AgentProfileInfo
}
// resolvePassthroughAgent loads the agent config and profile for a passthrough execution.
// Shared by passthroughAgentCommand, freshPassthroughCommand, and ResumePassthroughSession.
func (m *Manager) resolvePassthroughAgent(ctx context.Context, execution *AgentExecution) (*resolvedPassthrough, error) {
agentConfig, err := m.getAgentConfigForExecution(execution)
if err != nil {
return nil, fmt.Errorf("failed to get agent config: %w", err)
}
ptAgent, ok := agentConfig.(agents.PassthroughAgent)
if !ok {
return nil, fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID())
}
var profileInfo *AgentProfileInfo
if m.profileResolver != nil && execution.AgentProfileID != "" {
profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID)
}
return &resolvedPassthrough{
agentID: agentConfig.ID(),
agentConfig: agentConfig,
agent: ptAgent,
pt: ptAgent.PassthroughConfig(),
rt: agentConfig.Runtime(),
profile: profileInfo,
}, nil
}
// promptForPassthroughCommand returns the prompt that should be passed to
// BuildPassthroughCommand. When the agent uses idle-based auto-inject and has
// no PromptFlag, the prompt would otherwise be appended as a positional arg
// (putting TUIs like Claude into non-interactive `-p` mode and exiting before
// auto-inject fires). In that case we return "" so the prompt is delivered via
// PTY stdin in autoInjectInitialPrompt.
func promptForPassthroughCommand(pt agents.PassthroughConfig, taskDescription string) string {
if pt.AutoInjectPrompt && pt.PromptFlag.IsEmpty() {
return ""
}
return taskDescription
}
const (
// metadataKeyPassthroughMCPFiles tracks config files kandev wrote for the
// passthrough MCP injection so they can be removed when the execution ends.
metadataKeyPassthroughMCPFiles = "passthrough_mcp_files"
// metadataKeyPassthroughMCPEnv carries env vars the MCP strategy needs on the
// agent process (e.g. opencode's OPENCODE_CONFIG), merged in buildPassthroughEnv.
metadataKeyPassthroughMCPEnv = "passthrough_mcp_env"
// kandevMCPServerName is the reserved name of kandev's own HTTP MCP server,
// which exposes the task tools to the agent.
kandevMCPServerName = "kandev"
)
// redactPassthroughArgs masks secret-bearing MCP override values before the
// command is logged. Codex injects MCP servers via `-c mcp_servers.<name>.<key>=<json>`
// argv (no file-based option), so env vars and HTTP headers — which commonly
// carry tokens — would otherwise be written verbatim into backend logs. The
// real (unredacted) args are still what's executed; only the log copy is masked.
func redactPassthroughArgs(args []string) []string {
out := make([]string, len(args))
for i, a := range args {
out[i] = redactMCPArg(a)
}
return out
}
func redactMCPArg(arg string) string {
if !strings.HasPrefix(arg, "mcp_servers.") {
return arg
}
eq := strings.IndexByte(arg, '=')
if eq < 0 {
return arg
}
key := arg[:eq]
if strings.HasSuffix(key, ".env") || strings.HasSuffix(key, ".http_headers") {
return key + "=<redacted>"
}
return arg
}
func passthroughMCPConfigPort(execution *AgentExecution) int {
if execution == nil {
return 0
}
if execution.standalonePort > 0 {
return execution.standalonePort
}
if execution.Metadata == nil {
return 0
}
switch value := execution.Metadata["standalone_port"].(type) {
case int:
return value
case int64:
return int(value)
case float64:
return int(value)
}
return 0
}
func safePassthroughMCPConfigName(value string) string {
if value == "" {
return "session"
}
var out strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
out.WriteRune(r)
case r >= 'A' && r <= 'Z':
out.WriteRune(r)
case r >= '0' && r <= '9':
out.WriteRune(r)
case r == '-' || r == '_' || r == '.':
out.WriteRune(r)
default:
out.WriteByte('_')
}
}
return out.String()
}
// applyPassthroughMCP resolves the session's MCP servers (kandev's own server
// plus the profile's configured servers), runs the agent's passthrough MCP
// strategy, materializes any config files, records them for cleanup, stores the
// strategy's env vars on the execution (merged later in buildPassthroughEnv),
// and returns the extra CLI args to append to the passthrough command. It is a
// no-op for agents that declare no strategy.
func (m *Manager) applyPassthroughMCP(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, agentConfig agents.Agent) ([]string, error) {
if pt.MCPStrategy == nil {
return nil, nil
}
// passthroughMCPServers always returns at least the kandev server (or an
// error when the port is unavailable), so the strategy receives a non-empty
// list; each strategy guards its own empty-after-filtering case.
servers, err := m.passthroughMCPServers(ctx, execution, agentConfig)
if err != nil {
return nil, err
}
artifacts, err := pt.MCPStrategy.BuildPassthroughMCP(servers, m.passthroughMCPPaths(execution))
if err != nil {
return nil, fmt.Errorf("build passthrough MCP config: %w", err)
}
if err := m.writePassthroughMCPFiles(execution, artifacts.Files); err != nil {
return nil, err
}
setPassthroughMCPEnv(execution, artifacts.Env)
return artifacts.Args, nil
}
// passthroughMCPServers returns kandev's own HTTP MCP server followed by the
// profile's resolved MCP servers. The kandev server requires the standalone
// port; a profile server named "kandev" is dropped so it cannot shadow ours.
func (m *Manager) passthroughMCPServers(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) {
port := passthroughMCPConfigPort(execution)
if port <= 0 {
return nil, fmt.Errorf("standalone port unavailable for passthrough MCP config")
}
servers := []agentctltypes.McpServer{{
Name: kandevMCPServerName,
Type: string(mcpconfig.ServerTypeHTTP),
URL: fmt.Sprintf("http://localhost:%d/mcp", port),
}}
profileServers, err := m.resolveMcpServersWithParams(ctx, execution.AgentProfileID, execution.Metadata, agentConfig)
if err != nil {
return nil, err
}
for _, srv := range profileServers {
if srv.Name == kandevMCPServerName {
continue
}
servers = append(servers, srv)
}
return servers, nil
}
// passthroughMCPPaths computes the filesystem locations a strategy may use: a
// kandev-owned temp config path (for file+flag / file+env strategies) and the
// workspace dir (for project-local file strategies like Cursor).
func (m *Manager) passthroughMCPPaths(execution *AgentExecution) mcpconfig.PassthroughPaths {
root := m.dataDir
if root == "" {
root = filepath.Join(os.TempDir(), "kandev")
}
name := safePassthroughMCPConfigName(execution.SessionID)
if execution.SessionID == "" {
name = safePassthroughMCPConfigName(execution.ID)
}
return mcpconfig.PassthroughPaths{
TempConfigPath: filepath.Join(root, "passthrough-mcp", name+".json"),
WorkspaceDir: execution.WorkspacePath,
}
}
// writePassthroughMCPFiles materializes the strategy's config files and records
// every file kandev OWNS (created or overwrote) for cleanup. Files merged into a
// pre-existing user file (Cursor) are not tracked — kandev must not delete the
// user's file on teardown.
func (m *Manager) writePassthroughMCPFiles(execution *AgentExecution, files []mcpconfig.PassthroughConfigFile) error {
written := getPassthroughMCPFiles(execution)
for _, f := range files {
if f.Path == "" {
continue
}
ok, err := m.materializePassthroughFile(execution, f)
if err != nil {
return err
}
if ok {
written = appendUnique(written, f.Path)
}
}
setPassthroughMCPFiles(execution, written)
return nil
}
// materializePassthroughFile writes one config file and reports whether kandev
// OWNS the result (true = track for cleanup). It refuses to write through an
// existing symlink (a malicious repo could point it outside the worktree),
// guards against a symlinked parent escaping the worktree, and creates new files
// with O_EXCL. For MergeKey files (Cursor) that already exist, kandev's servers
// are merged into the user's file (preserving their entries) and the file is NOT
// tracked for cleanup since it is the user's.
func (m *Manager) materializePassthroughFile(execution *AgentExecution, f mcpconfig.PassthroughConfigFile) (bool, error) {
if escapes, err := workspacePathEscapes(execution.WorkspacePath, f.Path); err != nil {
return false, fmt.Errorf("validate passthrough MCP config path: %w", err)
} else if escapes {
m.logger.Warn("passthrough MCP config path escapes workspace via symlink; skipping",
zap.String("path", f.Path))
return false, nil
}
info, statErr := os.Lstat(f.Path)
switch {
case statErr == nil && info.Mode()&os.ModeSymlink != 0:
// Never write through an existing symlink — it could redirect the write
// outside the worktree. Applies to both merge and create.
m.logger.Warn("passthrough MCP config is a symlink; leaving it untouched",
zap.String("path", f.Path))
return false, nil
case statErr == nil && f.MergeKey != "":
// Merge kandev's servers into the user's existing regular file. Not
// tracked — it's the user's file; we only appended our entries.
return false, m.mergePassthroughConfig(f)
case statErr == nil:
// Existing kandev-owned temp file (Claude/OpenCode) — overwrite it.
if err := os.WriteFile(f.Path, f.Content, 0o600); err != nil {
return false, fmt.Errorf("write passthrough MCP config: %w", err)
}
return true, nil
case !os.IsNotExist(statErr):
return false, fmt.Errorf("lstat passthrough MCP config: %w", statErr)
default:
if err := os.MkdirAll(filepath.Dir(f.Path), 0o700); err != nil {
return false, fmt.Errorf("create passthrough MCP config dir: %w", err)
}
return m.writeFileNoFollow(f.Path, f.Content)
}
}
// mergePassthroughConfig merges kandev's servers (f.Content's f.MergeKey object)
// into the existing regular file at f.Path, preserving the user's other entries.
// A malformed or unreadable existing file is left untouched (logged), never
// clobbered. The file is confirmed to be a regular file (not a symlink) by the
// caller before this runs.
func (m *Manager) mergePassthroughConfig(f mcpconfig.PassthroughConfigFile) error {
existing, err := os.ReadFile(f.Path)
if err != nil {
m.logger.Warn("cannot read existing MCP config to merge; leaving it untouched",
zap.String("path", f.Path), zap.Error(err))
return nil
}
merged, err := mcpconfig.MergeJSONUnderKey(existing, f.Content, f.MergeKey)
if err != nil {
m.logger.Warn("cannot merge into existing MCP config; leaving it untouched",
zap.String("path", f.Path), zap.Error(err))
return nil
}
if err := os.WriteFile(f.Path, merged, 0o600); err != nil {
return fmt.Errorf("write merged passthrough MCP config: %w", err)
}
return nil
}
// writeFileNoFollow creates path with O_EXCL so it never follows or overwrites
// an existing leaf (including a symlink). A concurrently-created file is treated
// as "leave it alone" rather than an error.
func (m *Manager) writeFileNoFollow(path string, content []byte) (bool, error) {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
if os.IsExist(err) {
m.logger.Info("passthrough MCP config appeared concurrently; leaving it untouched",
zap.String("path", path))
return false, nil
}
return false, fmt.Errorf("create passthrough MCP config: %w", err)
}
if _, werr := file.Write(content); werr != nil {
_ = file.Close()
// Remove the empty file so a later SkipIfExists probe doesn't see it and
// silently skip writing the real config.
_ = os.Remove(path)
return false, fmt.Errorf("write passthrough MCP config: %w", werr)
}
if cerr := file.Close(); cerr != nil {
_ = os.Remove(path)
return false, fmt.Errorf("close passthrough MCP config: %w", cerr)
}
return true, nil
}
// workspacePathEscapes reports whether path — assumed to live under
// workspaceDir — would, after resolving symlinks on its deepest existing
// ancestor, land outside workspaceDir. Files not lexically under workspaceDir
// (kandev's own temp configs) are exempt and return false. An empty
// workspaceDir disables the check.
func workspacePathEscapes(workspaceDir, path string) (bool, error) {
if workspaceDir == "" {
return false, nil
}
cleanWS := filepath.Clean(workspaceDir)
cleanPath := filepath.Clean(path)
if rel, err := filepath.Rel(cleanWS, cleanPath); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false, nil // not a workspace-relative file; not our concern
}
canonWS, err := filepath.EvalSymlinks(cleanWS)
if err != nil {
return false, fmt.Errorf("resolve workspace dir: %w", err)
}
// Resolve the deepest existing ancestor of the target (the leaf and some
// parents may not exist yet — those get created as real dirs).
ancestor := filepath.Dir(cleanPath)
for {
resolved, err := filepath.EvalSymlinks(ancestor)
if err == nil {
rel, relErr := filepath.Rel(canonWS, resolved)
escaped := relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
return escaped, nil
}
if !os.IsNotExist(err) {
return false, fmt.Errorf("resolve ancestor %q: %w", ancestor, err)
}
parent := filepath.Dir(ancestor)
if parent == ancestor {
return false, nil // reached filesystem root with nothing existing
}
ancestor = parent
}
}
func (m *Manager) cleanupPassthroughMCPConfig(execution *AgentExecution) {
for _, path := range getPassthroughMCPFiles(execution) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
m.logger.Warn("failed to remove passthrough MCP config",
zap.String("path", path),
zap.Error(err))
}
}
if execution.Metadata != nil {
delete(execution.Metadata, metadataKeyPassthroughMCPFiles)
delete(execution.Metadata, metadataKeyPassthroughMCPEnv)
}
}
func appendUnique(list []string, value string) []string {
for _, v := range list {
if v == value {
return list
}
}
return append(list, value)
}
// getPassthroughMCPFiles reads the recorded config-file list, tolerating both
// []string (in-memory) and []interface{} (JSON-decoded after a restart).
func getPassthroughMCPFiles(execution *AgentExecution) []string {
if execution == nil || execution.Metadata == nil {
return nil
}
switch v := execution.Metadata[metadataKeyPassthroughMCPFiles].(type) {
case []string:
return append([]string(nil), v...)
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func setPassthroughMCPFiles(execution *AgentExecution, files []string) {
if execution.Metadata == nil {
execution.Metadata = map[string]interface{}{}
}
execution.Metadata[metadataKeyPassthroughMCPFiles] = files
}
// getPassthroughMCPEnv reads the recorded MCP env map, tolerating both
// map[string]string and map[string]interface{} (JSON-decoded after a restart).
func getPassthroughMCPEnv(execution *AgentExecution) map[string]string {
if execution == nil || execution.Metadata == nil {
return nil
}
switch v := execution.Metadata[metadataKeyPassthroughMCPEnv].(type) {
case map[string]string:
return v
case map[string]interface{}:
out := make(map[string]string, len(v))
for key, item := range v {
if s, ok := item.(string); ok {
out[key] = s
}
}
return out
default:
return nil
}
}
func setPassthroughMCPEnv(execution *AgentExecution, env map[string]string) {
if len(env) == 0 {
return
}
if execution.Metadata == nil {
execution.Metadata = map[string]interface{}{}
}
execution.Metadata[metadataKeyPassthroughMCPEnv] = env
}
// passthroughAgentCommand validates passthrough support and builds the command for a passthrough session.
// Returns the PassthroughAgent, PassthroughConfig, RuntimeConfig pointer, command, and any error.
func (m *Manager) passthroughAgentCommand(ctx context.Context, execution *AgentExecution, profileInfo *AgentProfileInfo) (agents.PassthroughAgent, agents.PassthroughConfig, *agents.RuntimeConfig, agents.Command, error) {
agentConfig, err := m.getAgentConfigForExecution(execution)
if err != nil {
return nil, agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("failed to get agent config: %w", err)
}
ptAgent, ok := agentConfig.(agents.PassthroughAgent)
if !ok {
return nil, agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID())
}
pt := ptAgent.PassthroughConfig()
rt := agentConfig.Runtime()
taskDescription := getTaskDescriptionFromMetadata(execution)
promptForCmd := promptForPassthroughCommand(pt, taskDescription)
mcpArgs, err := m.applyPassthroughMCP(ctx, execution, pt, agentConfig)
if err != nil {
return nil, agents.PassthroughConfig{}, nil, agents.Command{}, err
}
cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{
Model: effectivePassthroughModel(execution, profileInfo),
SessionID: execution.ACPSessionID,
Prompt: promptForCmd,
PermissionValues: profilePermissionValues(profileInfo),
MCPArgs: mcpArgs,
CLIFlagTokens: m.profileCLIFlagTokens(profileInfo),
})
if cmd.IsEmpty() {
return nil, agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID())
}
return ptAgent, pt, rt, cmd, nil
}
// profileCLIFlagTokens resolves the user-configured CLI flag argv tokens from
// a profile, mirroring the ACP launch path (manager_launch.go). Returns nil
// on resolve error and logs a warning so a malformed flag does not block the
// session — matches the warn-and-continue behaviour of the ACP path.
func (m *Manager) profileCLIFlagTokens(p *AgentProfileInfo) []string {
if p == nil {
return nil
}
tokens, err := cliflags.Resolve(p.CLIFlags)
if err != nil {
m.logger.Warn("failed to resolve cli_flags for passthrough profile, launching without user-configured flags",
zap.String("profile_id", p.ProfileID),
zap.Error(err))
return nil
}
return tokens
}
// buildInteractiveStartRequest builds the InteractiveStartRequest for a passthrough session.
// immediateStart overrides pt.WaitForTerminal when true (used for restart/resume where the
// terminal WebSocket is already connected).
func buildInteractiveStartRequest(sessionID string, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command, stripEnv []string, immediateStart bool) process.InteractiveStartRequest {
return process.InteractiveStartRequest{
SessionID: sessionID,
Command: cmd.Args(),
// Redacted copy logged in place of Command by the interactive runner so
// Codex MCP `-c` overrides (env/headers tokens) never reach process logs.
LogCommand: redactPassthroughArgs(cmd.Args()),
WorkingDir: execution.WorkspacePath,
Env: env,
StripEnv: stripEnv,
PromptPattern: pt.PromptPattern,
IdleTimeout: pt.IdleTimeout,
BufferMaxBytes: pt.BufferMaxBytes,
StatusDetector: pt.StatusDetector,
CheckInterval: pt.CheckInterval,
StabilityWindow: pt.StabilityWindow,
ImmediateStart: immediateStart,
DefaultCols: 120,
DefaultRows: 40,
}
}
// startInteractiveProcess launches the interactive PTY process for a passthrough session.
// Returns the process info on success.
func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command, stripEnv []string) (*process.InteractiveProcessInfo, error) {
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return nil, fmt.Errorf("interactive runner not available for passthrough mode")
}
// Always start immediately with default dimensions (120×40). The first resize
// from the terminal WebSocket will correct the size. Without immediate start,
// WaitForTerminal agents deadlock: the frontend won't connect the terminal
// until the session leaves STARTING, but the process never starts without a resize.
// This matches ResumePassthroughSession and restartPassthroughProcess.
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, stripEnv, true)
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
m.updateExecutionError(execution.ID, "failed to start passthrough session: "+err.Error())
return nil, fmt.Errorf("failed to start passthrough session: %w", err)
}
return processInfo, 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 {
_, pt, rt, cmd, err := m.passthroughAgentCommand(ctx, execution, profileInfo)
if err != nil {
return err
}
m.logger.Info("passthrough command built",
zap.String("session_id", execution.SessionID),
zap.Strings("full_command", redactPassthroughArgs(cmd.Args())))
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
processInfo, err := m.startInteractiveProcess(ctx, execution, pt, env, cmd, rt.StripEnv)
if err != nil {
return err
}
execution.PassthroughProcessID = processInfo.ID
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
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", redactPassthroughArgs(cmd.Args())))
m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "")
m.startPassthroughShell(ctx, execution, "failed to start shell for passthrough session")
if m.streamManager != nil && execution.agentctl != nil {
m.streamManager.ConnectWorkspaceStream(execution, nil)
// Also open the agent updates stream so the agentctl instance can proxy
// kandev MCP tool calls to the backend (passthrough has no ACP stream
// otherwise, so MCP tool calls would hang).
if !execution.agentctl.HasAgentStream() {
m.streamManager.ConnectMCPStream(execution)
}
}
go m.autoInjectInitialPrompt(execution, pt)
return nil
}
// profileModel extracts the model from profile info, returning empty string if nil.
func profileModel(p *AgentProfileInfo) string {
if p == nil {
return ""
}
return p.Model
}
// effectivePassthroughModel returns the model that should be passed to the next
// passthrough launch: a model_override on the execution wins over the profile's
// model. SetSessionModel sets the override for passthrough sessions because the
// model is baked into the CLI command at launch time — there is no live channel
// to swap it, so the PTY must be relaunched with a new --model.
func effectivePassthroughModel(execution *AgentExecution, profile *AgentProfileInfo) string {
if execution != nil && execution.Metadata != nil {
if override, ok := execution.Metadata[MetadataKeyModelOverride].(string); ok && override != "" {
return override
}
}
return profileModel(profile)
}
// profilePermissionValues builds a permission values map from profile info.
func profilePermissionValues(p *AgentProfileInfo) map[string]bool {
if p == nil {
return nil
}
values := map[string]bool{
"dangerously_skip_permissions": p.DangerouslySkipPermissions,
"allow_indexing": p.AllowIndexing,
}
values[agents.PermissionKeyAutoApprove] = p.AutoApprove
return values
}
// freshPassthroughCommand resolves the agent config and profile, and builds a
// bare passthrough command with no session, resume, or prompt flags.
func (m *Manager) freshPassthroughCommand(ctx context.Context, execution *AgentExecution) (agents.PassthroughConfig, *agents.RuntimeConfig, agents.Command, error) {
resolved, err := m.resolvePassthroughAgent(ctx, execution)
if err != nil {
return agents.PassthroughConfig{}, nil, agents.Command{}, err
}
mcpArgs, err := m.applyPassthroughMCP(ctx, execution, resolved.pt, resolved.agentConfig)
if err != nil {
return agents.PassthroughConfig{}, nil, agents.Command{}, err
}
cmd := resolved.agent.BuildPassthroughCommand(agents.PassthroughOptions{
Model: effectivePassthroughModel(execution, resolved.profile),
PermissionValues: profilePermissionValues(resolved.profile),
MCPArgs: mcpArgs,
CLIFlagTokens: m.profileCLIFlagTokens(resolved.profile),
})
if cmd.IsEmpty() {
return agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("passthrough command is empty for agent %s", resolved.agentID)
}
return resolved.pt, resolved.rt, cmd, nil
}
func (m *Manager) resumePassthroughCommand(ctx context.Context, execution *AgentExecution, resolved *resolvedPassthrough, useResume bool) (agents.Command, error) {
mcpArgs, err := m.applyPassthroughMCP(ctx, execution, resolved.pt, resolved.agentConfig)
if err != nil {
return agents.Command{}, err
}
cmd := resolved.agent.BuildPassthroughCommand(agents.PassthroughOptions{
Model: effectivePassthroughModel(execution, resolved.profile),
Resume: useResume,
PermissionValues: profilePermissionValues(resolved.profile),
MCPArgs: mcpArgs,
CLIFlagTokens: m.profileCLIFlagTokens(resolved.profile),
})
if cmd.IsEmpty() {
return agents.Command{}, fmt.Errorf("passthrough resume command is empty for agent %s", resolved.agentID)
}
return cmd, nil
}
// restartPassthroughProcess kills the current PTY process and relaunches a fresh one
// without --resume, effectively clearing the agent's conversation context.
// The workflow step prompt is delivered afterwards via stdin (autoStartPassthroughPrompt).
func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *AgentExecution) error {
m.logger.Info("restarting passthrough process for context reset",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
zap.String("old_process_id", execution.PassthroughProcessID))
// 1. Stop the current PTY process.
// Clear PassthroughProcessID before stopping so that handlePassthroughStatus
// doesn't trigger auto-restart for the deliberately-killed process.
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return fmt.Errorf("interactive runner not available")
}
oldProcessID := execution.PassthroughProcessID
execution.PassthroughProcessID = ""
execution.PassthroughStartedAt = time.Time{}
if err := interactiveRunner.Stop(ctx, oldProcessID); err != nil {
m.logger.Warn("failed to stop passthrough process during context reset",
zap.String("execution_id", execution.ID),
zap.String("process_id", oldProcessID),
zap.Error(err))
}
// 2. Build fresh command (no SessionID, no Resume, no Prompt)
pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution)
if err != nil {
return err
}
// 3. Start new PTY process with ImmediateStart (terminal is already connected)
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, rt.StripEnv, true)
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
m.updateExecutionError(execution.ID, "failed to restart passthrough session: "+err.Error())
return fmt.Errorf("failed to restart passthrough session: %w", err)
}
// 4. Update execution with new process ID
execution.PassthroughProcessID = processInfo.ID
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
m.logger.Info("passthrough process restarted with fresh context",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
zap.String("new_process_id", processInfo.ID))
// 5. Reconnect existing WebSocket to the new process
if interactiveRunner.ConnectSessionWebSocket(processInfo.ID) {
m.logger.Debug("reconnected WebSocket to restarted passthrough process",
zap.String("session_id", execution.SessionID),
zap.String("process_id", processInfo.ID))
}
// 6. Publish context reset event
m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution)
return nil
}
// ResumePassthroughSession restarts a passthrough session after backend restart.
// This is called when user reconnects to a terminal but the PTY process is no longer running.
// If the agent supports resume, it uses the resume flag to continue the last conversation.
// Otherwise, it starts a fresh CLI session with the same profile settings.
func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string) error {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
return fmt.Errorf("%w: %s", ErrNoExecutionForSession, sessionID)
}
resolved, err := m.resolvePassthroughAgent(ctx, execution)
if err != nil {
return err
}
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return fmt.Errorf("interactive runner not available")
}
// Skip the resume flag if a previous resume already fast-failed for this
// execution: re-attaching `-c` / `--resume` would just reproduce the same
// "No conversation found to continue" exit on every WS reconnect after
// backend restart. Once the sticky flag is set, every subsequent launch
// for this execution starts fresh.
useResume := !execution.passthroughResumeFailed
cmd, err := m.resumePassthroughCommand(ctx, execution, resolved, useResume)
if err != nil {
return err
}
m.logger.Info("resuming passthrough session",
zap.String("session_id", sessionID),
zap.String("execution_id", execution.ID),
zap.Bool("use_resume", useResume),
zap.Strings("command", redactPassthroughArgs(cmd.Args())))
env := m.buildPassthroughEnv(ctx, execution, resolved.rt.RequiredEnv)
// Always use immediate start on resume — the terminal WebSocket is already connected,
// so we don't need to wait for a resize to get exact dimensions. The first resize
// from the terminal will correct the dimensions. Without this, TUI apps that use
// WaitForTerminal would never start because the frontend may not send resizes
// to a process it doesn't know about yet.
startReq := buildInteractiveStartRequest(sessionID, execution, resolved.pt, env, cmd, resolved.rt.StripEnv, true)
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
return fmt.Errorf("failed to start passthrough session: %w", err)
}
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = useResume
execution.PassthroughProcessID = processInfo.ID
m.logger.Info("passthrough session resumed",
zap.String("session_id", sessionID),
zap.String("execution_id", execution.ID),
zap.String("process_id", processInfo.ID))
// Start shell session for workspace shell access (right panel terminal).
// This needs to be done after resume since the shell process was killed on backend restart.
m.startPassthroughShell(ctx, execution, "failed to start shell for resumed passthrough session")
// Connect to workspace stream for shell/git/file features.
// Only connect if not already connected (process restart reuses the same agentctl).
if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil {
m.streamManager.ConnectWorkspaceStream(execution, nil)
}
// Re-open the MCP proxy stream too (drains kandev MCP tool calls).
if m.streamManager != nil && execution.agentctl != nil && !execution.agentctl.HasAgentStream() {
m.streamManager.ConnectMCPStream(execution)
}
return nil
}
// 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: 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.
// When the process exits while a WebSocket is connected, it attempts auto-restart with rate limiting.
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: status.Kind,
Command: status.Command,
ScriptName: status.ScriptName,
WorkingDir: status.WorkingDir,
Status: status.Status,
ExitCode: status.ExitCode,
Timestamp: status.Timestamp,
}
m.eventPublisher.PublishProcessStatus(execution, clientStatus)
// Check if process exited and should be auto-restarted
// Only restart if this is the ACTUAL passthrough process, not user shell terminals
// Run asynchronously to allow the old process to be cleaned up first
if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed {
// Only trigger auto-restart for the passthrough process, not for user shell terminals
if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID {
// Snapshot the start time and resume-launch flag synchronously so the
// goroutine doesn't race the next launch's writes to those fields.
startedAt := execution.PassthroughStartedAt
usedResume := execution.passthroughLaunchUsedResume
// Detect fast-fail synchronously so we can flip the resume-failed
// flag before the next WS reconnect arrives. The goroutine below
// would otherwise miss this race: when the PTY exits the WS bridge
// closes too, and by the time the goroutine runs (after a 100ms
// cleanup delay) HasActiveWebSocketBySession returns false and it
// bails without setting any flags. Scoped to fast-fail+usedResume
// so a healthy resumed session that exits cleanly or crashes long
// after launch keeps its resume intent for auto-restart.
if usedResume && passthroughExitIsFastFail(startedAt, status) {
execution.passthroughResumeFailed = true
execution.isResumedSession = false
execution.passthroughLaunchUsedResume = false
}
go m.handlePassthroughExit(execution, status, startedAt, usedResume)
} else {
m.logger.Debug("process exited but not the passthrough process, skipping auto-restart",
zap.String("session_id", status.SessionID),
zap.String("exited_process_id", status.ProcessID),
zap.String("passthrough_process_id", execution.PassthroughProcessID))
}
}
}
// handlePassthroughExit handles auto-restart logic when a passthrough process exits.
// This function is called asynchronously to allow the old process to be cleaned up first.
// startedAt and usedResume are snapshots of the matching execution fields taken
// synchronously at the call site — passed in rather than re-read here to avoid
// racing with the next launch's writes to those fields.
func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate, startedAt time.Time, usedResume bool) {
const restartDelay = 500 * time.Millisecond
const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup
// fastFailWindow is short enough to catch launch-time failures (bad CLI
// flag, missing binary, immediate auth rejection) but long enough that a
// healthy agent that does any startup work won't be mistaken for one.
const fastFailWindow = 2 * time.Second
sessionID := execution.SessionID
if m.IsShuttingDown() {
m.logger.Debug("skipping passthrough auto-restart during shutdown",
zap.String("session_id", sessionID))
return
}
// Wait a bit for the old process to be cleaned up from the process map
time.Sleep(cleanupDelay)
// Shutdown may have started during cleanupDelay; re-check before emitting
// the "attempting auto-restart" log and the terminal banner, which would
// otherwise mislead the user during a clean shutdown.
if m.IsShuttingDown() {
m.logger.Debug("skipping passthrough auto-restart during shutdown",
zap.String("session_id", sessionID))
return
}
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
m.logger.Debug("no interactive runner available for auto-restart",
zap.String("session_id", sessionID))
return
}
// Check if WebSocket is still connected (use session-level tracking which survives process deletion)
if !interactiveRunner.HasActiveWebSocketBySession(sessionID) {
m.logger.Debug("no active WebSocket, skipping auto-restart",
zap.String("session_id", sessionID))
return
}
exitCode := 0
if status.ExitCode != nil {
exitCode = *status.ExitCode
}
// Use the exit timestamp from the status event (set when the child
// actually exited), not time.Now() — the cleanupDelay sleep and goroutine
// hops above would otherwise inflate the measured uptime by ~100 ms.
exitedAt := status.Timestamp
if exitedAt.IsZero() {
exitedAt = time.Now()
}
// Fast-fail short-circuit: a non-zero exit shortly after start almost
// always means the launch itself was wrong (bad CLI flag, missing binary,
// auth failure). Restarting just thrashes — the next run hits the same
// failure at the same speed. Surface the failure to the user instead.
if isFastFailExit(startedAt, exitedAt, exitCode, fastFailWindow) {
uptime := exitedAt.Sub(startedAt)
// If the failed launch was a resume (e.g. `--resume <id>` or `-c`),
// the most likely cause is a stale conversation ID after a backend
// restart — "No conversation found to continue". Retry once with a
// fresh command (no resume flag) before giving up.
if usedResume {
// Resume-failed flags have already been flipped synchronously in
// handlePassthroughStatus (see passthroughExitIsFastFail) so any
// concurrent WS reconnect that races this goroutine sees the new
// values immediately.
m.attemptResumeFallback(execution, interactiveRunner, sessionID, exitCode, uptime)
return
}
m.notifyFastFailExit(interactiveRunner, sessionID, uptime, exitCode, fastFailWindow)
return
}
m.attemptPassthroughRestart(execution, interactiveRunner, sessionID, exitCode, restartDelay)
}
// attemptResumeFallback recovers from a fast-failed resume launch by relaunching
// once with a fresh command (no resume flag). This handles the common case where
// the local CLI's conversation history is gone after a backend restart and
// `claude -c` / `claude --resume <id>` exits with "No conversation found to
// continue". On a successful fallback the user keeps a working session; on
// continued failure we surface the existing red banner so they can fix their
// profile.
func (m *Manager) attemptResumeFallback(execution *AgentExecution, runner *process.InteractiveRunner, sessionID string, exitCode int, uptime time.Duration) {
m.logger.Info("passthrough resume launch fast-failed, retrying without resume flag",
zap.String("session_id", sessionID),
zap.String("execution_id", execution.ID),
zap.Int("exit_code", exitCode),
zap.Duration("uptime", uptime))
if m.IsShuttingDown() {
m.logger.Debug("skipping passthrough resume fallback during shutdown",
zap.String("session_id", sessionID))
return
}
if !runner.HasActiveWebSocketBySession(sessionID) {
m.logger.Debug("no active WebSocket, skipping passthrough resume fallback",
zap.String("session_id", sessionID))
return
}
banner := "\r\n\x1b[33m[No prior conversation to resume — starting a fresh session...]\x1b[0m\r\n"
if err := runner.WriteToDirectOutputBySession(sessionID, []byte(banner)); err != nil {
m.logger.Debug("failed to write resume-fallback banner to terminal",
zap.String("session_id", sessionID),
zap.Error(err))
}
ctx := context.Background()
pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution)
if err != nil {
m.notifyFallbackInfrastructureFailure(runner, sessionID, "build fresh command", err)
return
}
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
startReq := buildInteractiveStartRequest(sessionID, execution, pt, env, cmd, rt.StripEnv, true)
processInfo, err := runner.Start(ctx, startReq)
if err != nil {
m.notifyFallbackInfrastructureFailure(runner, sessionID, "start fresh process", err)
return
}
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
execution.PassthroughProcessID = processInfo.ID
if runner.ConnectSessionWebSocket(processInfo.ID) {
m.logger.Info("passthrough resume fallback succeeded",
zap.String("session_id", sessionID),
zap.String("execution_id", execution.ID),
zap.String("new_process_id", processInfo.ID))
} else {
m.logger.Warn("passthrough resume fallback started but failed to reconnect WebSocket",
zap.String("session_id", sessionID),
zap.String("new_process_id", processInfo.ID))
}
// Mirror ResumePassthroughSession's post-launch bootstrap so right-panel
// shell/git/file features come back too — without this the main terminal
// works but the user's shell session and workspace stream stay torn down.
m.startPassthroughShell(ctx, execution, "failed to start shell after passthrough resume fallback")
if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil {
m.streamManager.ConnectWorkspaceStream(execution, nil)
}
if m.streamManager != nil && execution.agentctl != nil && !execution.agentctl.HasAgentStream() {
m.streamManager.ConnectMCPStream(execution)
}
// Fallback path is a fresh session (no --resume) — re-inject the prompt.
go m.autoInjectInitialPrompt(execution, pt)
}
// attemptPassthroughRestart announces the restart on the terminal, waits the
// restart delay, re-checks shutdown/WebSocket, and resumes the session.
// Reconnects the existing WebSocket to the new process on success.
func (m *Manager) attemptPassthroughRestart(execution *AgentExecution, runner *process.InteractiveRunner, sessionID string, exitCode int, restartDelay time.Duration) {
m.logger.Info("passthrough process exited with active WebSocket, attempting auto-restart",
zap.String("session_id", sessionID),
zap.Int("exit_code", exitCode))
restartMsg := "\r\n\x1b[33m[Agent exited. Restarting...]\x1b[0m\r\n"
if err := runner.WriteToDirectOutputBySession(sessionID, []byte(restartMsg)); err != nil {
m.logger.Debug("failed to write restart message to terminal",
zap.String("session_id", sessionID),
zap.Error(err))
}
time.Sleep(restartDelay)
// Shutdown may have started during the sleep; re-check before touching
// state that the teardown is racing to remove.
if m.IsShuttingDown() {
m.logger.Debug("skipping passthrough auto-restart during shutdown",
zap.String("session_id", sessionID))
return
}
if !runner.HasActiveWebSocketBySession(sessionID) {
m.logger.Debug("WebSocket disconnected during restart delay, aborting",
zap.String("session_id", sessionID))
return
}
if err := m.ResumePassthroughSession(context.Background(), sessionID); err != nil {
m.logger.Error("failed to auto-restart passthrough session",
zap.String("session_id", sessionID),
zap.Error(err))
errorMsg := fmt.Sprintf("\r\n\x1b[31m[Restart failed: %s]\x1b[0m\r\n", err.Error())
if writeErr := runner.WriteToDirectOutputBySession(sessionID, []byte(errorMsg)); writeErr != nil {
m.logger.Debug("failed to write restart error message to terminal",
zap.String("session_id", sessionID),
zap.Error(writeErr))
}
return
}
if runner.ConnectSessionWebSocket(execution.PassthroughProcessID) {
m.logger.Info("passthrough session auto-restarted and reconnected WebSocket",
zap.String("session_id", sessionID),
zap.String("new_process_id", execution.PassthroughProcessID))
} else {
m.logger.Warn("passthrough session restarted but failed to reconnect WebSocket",
zap.String("session_id", sessionID),
zap.String("new_process_id", execution.PassthroughProcessID))
}
}
// notifyFallbackInfrastructureFailure surfaces an attemptResumeFallback
// failure that originated from kandev's own machinery (could not build the
// fresh command, could not start the new process) rather than from the
// agent CLI itself. The existing fast-fail banner blames a "bad CLI flag,
// missing binary, or auth failure" — wrong copy for these paths, which
// the user can't fix by editing their profile.
func (m *Manager) notifyFallbackInfrastructureFailure(runner *process.InteractiveRunner, sessionID, stage string, err error) {
m.logger.Error("passthrough resume fallback failed",
zap.String("session_id", sessionID),
zap.String("stage", stage),
zap.Error(err))
failMsg := fmt.Sprintf("\r\n\x1b[31m[Resume fallback failed: could not %s — %s. Please reconnect to retry.]\x1b[0m\r\n",
stage, err.Error())
if writeErr := runner.WriteToDirectOutputBySession(sessionID, []byte(failMsg)); writeErr != nil {
m.logger.Debug("failed to write resume-fallback infra-failure banner to terminal",
zap.String("session_id", sessionID),
zap.Error(writeErr))
}
}
// notifyFastFailExit logs the fast-fail decision and writes a one-shot
// banner to the terminal explaining why the auto-restart was skipped.
// uptime is the measured process lifetime (status timestamp minus start
// time), pre-computed by the caller so the log reflects true child uptime.
func (m *Manager) notifyFastFailExit(runner *process.InteractiveRunner, sessionID string, uptime time.Duration, exitCode int, window time.Duration) {
m.logger.Warn("passthrough process exited fast with non-zero code, skipping auto-restart",
zap.String("session_id", sessionID),
zap.Int("exit_code", exitCode),
zap.Duration("uptime", uptime))
failMsg := fmt.Sprintf("\r\n\x1b[31m[Agent exited (code %d) within %s. Likely cause: bad CLI flag, missing binary, or auth failure. Edit your profile and reconnect to retry.]\x1b[0m\r\n",
exitCode, window)
if err := runner.WriteToDirectOutputBySession(sessionID, []byte(failMsg)); err != nil {
m.logger.Debug("failed to write fast-fail message to terminal",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
// passthroughExitIsFastFail wraps isFastFailExit for the synchronous
// status-callback path that doesn't yet have the unpacked exit code or
// timestamp. Same window/semantics as the goroutine path.
func passthroughExitIsFastFail(startedAt time.Time, status *agentctltypes.ProcessStatusUpdate) bool {
const fastFailWindow = 2 * time.Second
exitCode := 0
if status.ExitCode != nil {
exitCode = *status.ExitCode
}
exitedAt := status.Timestamp
if exitedAt.IsZero() {
exitedAt = time.Now()
}
return isFastFailExit(startedAt, exitedAt, exitCode, fastFailWindow)
}
// isFastFailExit reports whether a passthrough process exit looks like a
// launch failure rather than a runtime exit worth restarting. A zero start
// time disables the check (e.g. recovered executions where the start time
// is unknown), so the legacy restart path remains the default. exitedAt is
// the wall-clock time the process actually exited (status.Timestamp), kept
// distinct from time.Now() so the cleanupDelay sleep above the call site
// doesn't shrink the effective window.
func isFastFailExit(startedAt, exitedAt time.Time, exitCode int, window time.Duration) bool {
if exitCode == 0 || startedAt.IsZero() {
return false
}
return exitedAt.Sub(startedAt) < window
}
// passthroughRunner is the minimal seam autoInjectInitialPrompt needs from
// *process.InteractiveRunner. Defined as an interface so tests can supply a
// fake runner without spinning up a real PTY subprocess.
type passthroughRunner interface {
WaitForFirstIdle(ctx context.Context, processID string) error
WriteStdin(processID string, data string) error
}
// autoInjectInitialPrompt writes the task description to the PTY stdin once
// the agent is idle (ready for input). Opt-in per agent via PassthroughConfig.
// Called from startPassthroughSession and attemptResumeFallback only — never
// from ResumePassthroughSession (would duplicate the prompt in agent history).
func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig) {
runner := m.GetInteractiveRunner()
if runner == nil {
return
}
m.autoInjectInitialPromptWith(runner, execution, pt)
}
// autoInjectInitialPromptWith is the testable inner of autoInjectInitialPrompt,
// taking a runner seam so unit tests can avoid spawning a real PTY.
func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, execution *AgentExecution, pt agents.PassthroughConfig) {
if !pt.AutoInjectPrompt {
return
}
if !pt.PromptFlag.IsEmpty() {
// The agent already received the prompt as a CLI flag.
return
}
description := getTaskDescriptionFromMetadata(execution)
if description == "" {
return
}
processID := execution.PassthroughProcessID
if processID == "" {
m.logger.Warn("autoInjectInitialPrompt called without passthrough process",
zap.String("execution_id", execution.ID))
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := runner.WaitForFirstIdle(ctx, processID); err != nil {
m.logger.Warn("autoInjectInitialPrompt timed out waiting for idle",
zap.String("execution_id", execution.ID),
zap.String("process_id", processID),
zap.Error(err))
return
}
// WaitForFirstIdle also unblocks when the process exits — skip the write
// during shutdown so we don't race the lifecycle manager's teardown. If the
// process is already gone, WriteStdin returns "process not found" and the
// existing error branch logs it.
if m.IsShuttingDown() {
return
}
// Mark RUNNING before the chunk loop so a composer/message.add fired during
// the inter-chunk SubmitDelay window (150ms for Claude) is blocked by
// checkSessionPromptable instead of racing into the same PTY mid-submit.
if err := m.MarkPassthroughRunning(execution.SessionID); err != nil {
m.logger.Warn("failed to mark passthrough as running before auto-inject",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
zap.Error(err))
}
for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) {
if chunk.DelayBefore > 0 {
time.Sleep(chunk.DelayBefore)
}
if err := runner.WriteStdin(processID, chunk.Data); err != nil {
m.logger.Warn("autoInjectInitialPrompt write failed",
zap.String("execution_id", execution.ID),
zap.String("process_id", processID),
zap.Error(err))
return
}
}
m.logger.Info("autoInjectInitialPrompt wrote task description to PTY",
zap.String("execution_id", execution.ID),
zap.String("process_id", processID),
zap.Int("description_len", len(description)))
}
// ResolvePassthroughConfig returns the PassthroughConfig for a session's agent.
// Used by callers outside this package (e.g. orchestrator) that need the submit
// sequence to write to PTY stdin.
func (m *Manager) ResolvePassthroughConfig(ctx context.Context, sessionID string) (agents.PassthroughConfig, error) {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
return agents.PassthroughConfig{}, fmt.Errorf("no execution for session %q", sessionID)
}
resolved, err := m.resolvePassthroughAgent(ctx, execution)
if err != nil {
return agents.PassthroughConfig{}, err
}
return resolved.pt, nil
}
// 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.executorRegistry == nil {
return nil
}
standaloneRT, err := m.executorRegistry.GetBackend(executor.NameStandalone)
if err != nil {
return nil
}
return standaloneRT.GetInteractiveRunner()
}