Files
kandev/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go
T
João Salavisa 16ca1657ce fix(ui): group mobile system metrics (#3880)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-09-23 12:00:02 +01:00

1768 lines
70 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"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"sync"
"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"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"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"
)
var errPassthroughProcessReplaced = errors.New("passthrough process was replaced")
// PreparePassthroughRunning marks a passthrough execution as running and
// returns a one-shot callback that publishes the corresponding AgentRunning
// event. Callers that hold a session serialization guard must invoke the
// callback after releasing that guard because event subscribers may re-enter
// it synchronously.
func (m *Manager) PreparePassthroughRunning(sessionID string) (func(), error) {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
return nil, fmt.Errorf("no agent execution found for session: %s", sessionID)
}
var payload AgentEventPayload
var updated *AgentExecution
var alreadyRunning bool
var validationErr error
if err := m.executionStore.WithLock(execution.ID, func(current *AgentExecution) {
// Revalidate the session mapping and passthrough mode while claiming the
// transition. The initial lookup only supplies the execution ID to lock.
if current.SessionID != sessionID {
validationErr = fmt.Errorf("no agent execution found for session: %s", sessionID)
return
}
if current.PassthroughProcessID == "" {
validationErr = fmt.Errorf("session %s is not in passthrough mode", sessionID)
return
}
// Only publish if not already running (prevents duplicate events).
if current.Status == v1.AgentStatusRunning {
alreadyRunning = true
return
}
current.Status = v1.AgentStatusRunning
updated = current
// Capture the payload under the same lock as the status claim so a
// competing stop/failure cannot relabel the deferred event.
payload = newAgentEventPayload(current)
}); err != nil {
if errors.Is(err, ErrExecutionNotFound) {
return nil, fmt.Errorf("no agent execution found for session: %s", sessionID)
}
return nil, err
}
if validationErr != nil {
return nil, validationErr
}
if alreadyRunning {
return func() {}, nil
}
m.persistExecutorRunning(context.Background(), updated)
var publishOnce sync.Once
return func() {
publishOnce.Do(func() {
m.eventPublisher.publishAgentEventPayload(context.Background(), events.AgentRunning, payload)
})
}, nil
}
// MarkPassthroughRunning marks a passthrough execution as running when the user submits input
// via the terminal handler. It is a convenience wrapper around PreparePassthroughRunning that
// publishes the AgentRunning event immediately.
//
// Callers that hold the per-session cancellation guard (e.g. handleAgentReady) must use
// PreparePassthroughRunning directly and defer the returned publisher until after the guard
// is released, to avoid re-entering the mutex through the synchronous event subscriber.
func (m *Manager) MarkPassthroughRunning(sessionID string) error {
publish, err := m.PreparePassthroughRunning(sessionID)
if err != nil {
return err
}
publish()
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, error) {
env := execution.RuntimeEnvironment()
hasRuntimeSnapshot := env != nil
if env == nil {
env = make(map[string]string)
}
env["KANDEV_TASK_ID"] = execution.TaskID
env["KANDEV_SESSION_ID"] = execution.SessionID
env["KANDEV_AGENT_PROFILE_ID"] = execution.officeProfileID()
env["KANDEV_EXECUTION_PROFILE_ID"] = execution.AgentProfileID
if !hasRuntimeSnapshot {
if err := m.mergeAgentProfileEnvForExecution(ctx, execution, env); err != nil {
return nil, fmt.Errorf("resolve passthrough profile environment: %w", err)
}
}
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, nil
}
// 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) {
client, release := execution.AcquireAgentCtlClient()
defer release()
if client == nil {
return
}
if err := client.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)
}
if err := validatePassthroughProvider(profileInfo); err != nil {
return nil, err
}
return &resolvedPassthrough{
agentID: agentConfig.ID(),
agentConfig: agentConfig,
agent: ptAgent,
pt: ptAgent.PassthroughConfig(),
rt: agentConfig.Runtime(),
profile: profileInfo,
}, nil
}
func validatePassthroughProvider(profile *AgentProfileInfo) error {
if profile == nil || profile.ProviderKind != settingsmodels.ProviderKindOpenAICompatible {
return nil
}
return errors.New("CLI passthrough cannot use an OpenAI-compatible provider")
}
// promptForPassthroughCommand returns the prompt that should be passed to
// BuildPassthroughCommand.
//
// When the agent has a PromptFlag, the prompt rides that flag and is returned
// here. When it does not, the prompt is suppressed: BuildPassthroughCommand
// would otherwise append it as a positional argument, which interactive TUIs
// (claude, codex, fuelclaude, …) ignore or misinterpret — e.g. `zsh -ic
// "fuelclaude --model opus" "<prompt>"` drops the prompt as an unreferenced
// positional and the agent starts at an empty prompt. In the no-flag case the
// prompt is delivered instead via PTY stdin in autoInjectInitialPrompt.
//
// AutoInjectPrompt alone no longer gates suppression: a custom TUI agent with
// neither a PromptFlag nor AutoInjectPrompt (the default for agents built from
// a tui_config) would otherwise have no delivery path at all, so suppression is
// keyed on the absence of a PromptFlag, which is the actual "can't carry the
// prompt on the CLI" condition.
func promptForPassthroughCommand(pt agents.PassthroughConfig, taskDescription string) string {
if 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
}
return execution.metadataInt("standalone_port")
}
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.MetadataSnapshot(), 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))
}
}
execution.deleteMetadataValues(metadataKeyPassthroughMCPFiles, 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 {
return nil
}
value, _ := execution.metadataValue(metadataKeyPassthroughMCPFiles)
switch v := value.(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) {
execution.setMetadataValue(metadataKeyPassthroughMCPFiles, files)
}
// getPassthroughMCPEnv reads the recorded MCP env map, tolerating both
// map[string]string and map[string]interface{} (JSON-decoded after a restart).
// Like getPassthroughMCPFiles it returns a copy, so a caller that mutates the
// result cannot reach the stored map behind metadataMu's back.
func getPassthroughMCPEnv(execution *AgentExecution) map[string]string {
if execution == nil {
return nil
}
value, _ := execution.metadataValue(metadataKeyPassthroughMCPEnv)
switch v := value.(type) {
case map[string]string:
out := make(map[string]string, len(v))
maps.Copy(out, v)
return out
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
}
execution.setMetadataValue(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, err := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
if err != nil {
return err
}
execution.remoteInstanceLifecycleMu.Lock()
defer execution.remoteInstanceLifecycleMu.Unlock()
if err := m.ensureLaunchSessionStillActive(ctx, execution.SessionID, executionAdmissionAgent); err != nil {
return err
}
processInfo, err := m.startInteractiveProcess(ctx, execution, pt, env, cmd, rt.StripEnv)
if err != nil {
return err
}
execution.passthroughLifecycleMu.Lock()
execution.PassthroughProcessID = processInfo.ID
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
if requiresPassthroughInitialPrompt(execution, pt) {
execution.passthroughInitialPromptProcessID = processInfo.ID
} else {
execution.passthroughInitialPromptProcessID = ""
}
execution.passthroughLifecycleMu.Unlock()
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")
client, releaseClient := execution.AcquireAgentCtlClient()
hasAgentStream := client != nil && client.HasAgentStream()
releaseClient()
if m.streamManager != nil && client != 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 !hasAgentStream {
m.streamManager.ConnectMCPStream(execution)
}
}
go m.autoInjectInitialPrompt(execution, pt, processInfo.ID)
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 override := execution.metadataString(MetadataKeyModelOverride); 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 {
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return fmt.Errorf("interactive runner not available")
}
execution.passthroughLifecycleMu.Lock()
processID, err := m.replacePassthroughProcess(ctx, execution, interactiveRunner)
execution.passthroughLifecycleMu.Unlock()
if err != nil {
return err
}
// First-idle callbacks acquire the lifecycle lock. Settle startup outside
// that lock before a workflow prompt can claim the next running turn.
readyCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
if err := interactiveRunner.WaitForFirstIdle(readyCtx, processID); err != nil {
return fmt.Errorf("wait for restarted passthrough readiness: %w", err)
}
return nil
}
func (m *Manager) replacePassthroughProcess(ctx context.Context, execution *AgentExecution, interactiveRunner *process.InteractiveRunner) (string, 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. Resolve the replacement command and environment before touching the
// current PTY. A profile-secret failure must leave the active session usable.
// Build fresh command (no SessionID, no Resume, no Prompt).
pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution)
if err != nil {
return "", err
}
env, err := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
if err != nil {
return "", err
}
// 2. Stop the current PTY process. Clear PassthroughProcessID before
// stopping so handlePassthroughStatus does not trigger auto-restart for
// the deliberately-killed process.
oldProcessID := execution.PassthroughProcessID
execution.PassthroughProcessID = ""
execution.passthroughInitialPromptProcessID = ""
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))
}
// 3. Start new PTY process with ImmediateStart (terminal is already connected)
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
execution.passthroughInitialPromptProcessID = ""
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 processInfo.ID, 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 {
return m.resumePassthroughSession(ctx, sessionID, "")
}
func (m *Manager) passthroughProcessMatches(execution *AgentExecution, processID string) bool {
if execution == nil || processID == "" {
return false
}
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
return execution.PassthroughProcessID == processID
}
// resumePassthroughSession is the shared resume path for user reconnects and
// delayed exit recovery. expectedProcessID is set by exit recovery so an old
// callback cannot replace a process installed by a workflow reset.
func (m *Manager) resumePassthroughSession(ctx context.Context, sessionID, expectedProcessID string) error {
if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil {
return err
}
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
return fmt.Errorf("%w: %s", ErrNoExecutionForSession, sessionID)
}
execution.remoteInstanceLifecycleMu.Lock()
defer execution.remoteInstanceLifecycleMu.Unlock()
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
if expectedProcessID != "" && execution.PassthroughProcessID != expectedProcessID {
return errPassthroughProcessReplaced
}
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, err := m.buildPassthroughEnv(ctx, execution, resolved.rt.RequiredEnv)
if err != nil {
return err
}
// 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)
if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil {
return err
}
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
return fmt.Errorf("failed to start passthrough session: %w", err)
}
execution.PassthroughStartedAt = time.Now()
// Must precede PassthroughProcessID: handlePassthroughStatus reads
// passthroughLaunchUsedResume synchronously once the process ID is visible
// (the ID is what routes a status update to this execution), so the flag has
// to be set before the process can exit and publish a status.
execution.passthroughLaunchUsedResume = useResume
execution.passthroughInitialPromptProcessID = ""
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).
client, releaseClient := execution.AcquireAgentCtlClient()
hasAgentStream := client != nil && client.HasAgentStream()
releaseClient()
if m.streamManager != nil && client != 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 && client != nil && !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.
// A process can be replaced while its idle timer callback is already queued;
// reject that stale callback so it cannot mark the replacement execution ready
// and suppress the replacement process's real completion event.
func (m *Manager) handlePassthroughTurnComplete(sessionID, processID 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
}
execution.passthroughLifecycleMu.Lock()
activeProcessID := execution.PassthroughProcessID
initialPromptPending := execution.passthroughInitialPromptProcessID == processID
execution.passthroughLifecycleMu.Unlock()
if activeProcessID != processID {
m.logger.Debug("ignoring stale passthrough turn complete",
zap.String("session_id", sessionID),
zap.String("process_id", processID),
zap.String("active_process_id", activeProcessID))
return
}
if initialPromptPending {
m.logger.Debug("ignoring passthrough turn complete while initial prompt is pending",
zap.String("session_id", sessionID),
zap.String("process_id", processID))
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
// Flip the resume-failed flag synchronously so we beat the next WS
// reconnect. 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.
//
// Any non-zero exit of a resume launch counts, not just a fast one:
// a real CLI takes seconds to boot before it can report "No
// conversation found to continue", which lands outside the
// fast-fail window and previously left the flag unset — so every
// restart re-attached the same broken resume flag and the session
// looped forever (issue #2330). A clean (zero) exit still keeps the
// resume intent, so a healthy resumed session that the user quits
// is auto-restarted as a resume.
if usedResume && passthroughExitCode(status) != 0 {
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
}
if !m.passthroughProcessMatches(execution, status.ProcessID) {
m.logger.Debug("skipping stale passthrough auto-restart",
zap.String("session_id", sessionID),
zap.String("exited_process_id", status.ProcessID))
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
}
if !m.passthroughProcessMatches(execution, status.ProcessID) {
m.logger.Debug("skipping stale passthrough auto-restart after cleanup",
zap.String("session_id", sessionID),
zap.String("exited_process_id", status.ProcessID))
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 := passthroughExitCode(status)
// 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 so any concurrent WS reconnect that
// races this goroutine sees the new values immediately.
m.attemptResumeFallbackForProcess(execution, interactiveRunner, sessionID, status.ProcessID, exitCode, uptime)
return
}
m.notifyFastFailExit(interactiveRunner, sessionID, uptime, exitCode, fastFailWindow)
return
}
m.attemptPassthroughRestartForProcess(execution, interactiveRunner, sessionID, status.ProcessID, 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.attemptResumeFallbackForProcess(execution, runner, sessionID, "", exitCode, uptime)
}
func (m *Manager) attemptResumeFallbackForProcess(execution *AgentExecution, runner *process.InteractiveRunner, sessionID, expectedProcessID string, exitCode int, uptime time.Duration) {
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
if expectedProcessID != "" && execution.PassthroughProcessID != expectedProcessID {
m.logger.Debug("skipping stale passthrough resume fallback",
zap.String("session_id", sessionID),
zap.String("exited_process_id", expectedProcessID))
return
}
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, err := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
if err != nil {
m.notifyFallbackInfrastructureFailure(runner, sessionID, "resolve profile environment", err)
return
}
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 requiresPassthroughInitialPrompt(execution, pt) {
execution.passthroughInitialPromptProcessID = processInfo.ID
} else {
execution.passthroughInitialPromptProcessID = ""
}
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")
client, releaseClient := execution.AcquireAgentCtlClient()
hasAgentStream := client != nil && client.HasAgentStream()
releaseClient()
if m.streamManager != nil && client != nil && execution.GetWorkspaceStream() == nil {
m.streamManager.ConnectWorkspaceStream(execution, nil)
}
if m.streamManager != nil && client != nil && !hasAgentStream {
m.streamManager.ConnectMCPStream(execution)
}
// Fallback path is a fresh session (no --resume) — re-inject the prompt.
go m.autoInjectInitialPrompt(execution, pt, processInfo.ID)
}
// 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.attemptPassthroughRestartForProcess(execution, runner, sessionID, "", exitCode, restartDelay)
}
func (m *Manager) attemptPassthroughRestartForProcess(execution *AgentExecution, runner *process.InteractiveRunner, sessionID, expectedProcessID string, exitCode int, restartDelay time.Duration) {
if expectedProcessID != "" && !m.passthroughProcessMatches(execution, expectedProcessID) {
m.logger.Debug("skipping stale passthrough restart",
zap.String("session_id", sessionID),
zap.String("exited_process_id", expectedProcessID))
return
}
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, expectedProcessID); err != nil {
if errors.Is(err, errPassthroughProcessReplaced) {
m.logger.Debug("skipping stale passthrough restart after process replacement",
zap.String("session_id", sessionID),
zap.String("exited_process_id", expectedProcessID))
return
}
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))
}
}
// passthroughExitCode unpacks the exit code from a process status update,
// treating an absent code as a clean exit.
func passthroughExitCode(status *agentctltypes.ProcessStatusUpdate) int {
if status.ExitCode == nil {
return 0
}
return *status.ExitCode
}
// 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
}
func requiresPassthroughInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig) bool {
return pt.PromptFlag.IsEmpty() && getTaskDescriptionFromMetadata(execution) != ""
}
// claimPassthroughInitialPrompt verifies that this goroutine is still
// associated with the active PTY process before proceeding with injection.
// startPassthroughSession pre-sets the marker; this check deliberately does
// not claim a marker for a replacement process.
func claimPassthroughInitialPrompt(execution *AgentExecution, processID string) bool {
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
if execution.PassthroughProcessID != processID || execution.passthroughInitialPromptProcessID != processID {
return false
}
return true
}
func clearPassthroughInitialPrompt(execution *AgentExecution, processID string) {
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
if execution.passthroughInitialPromptProcessID == processID {
execution.passthroughInitialPromptProcessID = ""
}
}
// autoInjectInitialPrompt writes the task description to PTY stdin once a
// passthrough agent without a PromptFlag is idle (ready for input). 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, processID string) {
runner := m.GetInteractiveRunner()
if runner == nil {
return
}
m.autoInjectInitialPromptWith(runner, execution, pt, processID)
}
// 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, processID string) {
if !pt.PromptFlag.IsEmpty() {
// The agent already received the prompt as a CLI flag — no stdin
// delivery. This mirrors promptForPassthroughCommand, which only
// passes the prompt to BuildPassthroughCommand when a PromptFlag
// exists; the two no-flag cases (built-in AutoInjectPrompt agents
// and custom TUI agents) both land here for stdin delivery.
return
}
description := getTaskDescriptionFromMetadata(execution)
if description == "" {
// Nothing to inject (e.g. a non-LLM TUI tool with no task
// description). Generic passthrough tools launched without a task
// take this exit and never receive spurious stdin.
return
}
if processID == "" {
m.logger.Warn("autoInjectInitialPrompt called without passthrough process",
zap.String("execution_id", execution.ID))
return
}
if !claimPassthroughInitialPrompt(execution, processID) {
return
}
defer clearPassthroughInitialPrompt(execution, processID)
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
}
if !m.passthroughProcessMatches(execution, processID) {
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()
}