bc218dfbbe
* feat: add workspace sources from files panel * fix: preserve worktree identity during recovery * feat: unify workspace source actions * fix: validate recovered workspace repositories * fix: harden workspace source filesystem boundaries * test: split workspace repository chip coverage * fix: restrict repository rescan recovery paths * fix: stabilize workspace source CI checks * test: canonicalize workspace source paths * test: align workspace source container paths * fix: populate direct workspace repository names * test: preserve git fixture rewrites across restarts * test: isolate backend git fixture config * fix: resume remote workspace executions before creation * fix: preserve ssh agent identity during resume * fix: preserve SSH workspace across backend restart * fix: simplify workspace actions trigger * fix: refine add workspace repositories UX * docs: narrow workspace source UX updates * fix: avoid phantom turn on session resume * test(web): normalize workspace source request headers * feat(web): streamline workspace source picker * docs: remove trailing spaces from workspace source plan * fix: prevent resume status from creating phantom turns * fix(web): stabilize workspace source attachment * refactor(web): extract workspace source store contract * fix: avoid phantom turns on idle agent resume * fix: address backend static analysis findings * fix: preserve agentctl readiness status value * fix: preserve resume messages without active turns * fix: resume agents from promoted workspace root * chore: merge origin/main into feature/add-new-repo-folder-d97 * feat(web): explain workspace source consequences * test(websocket): count session activity subscription
473 lines
19 KiB
Go
473 lines
19 KiB
Go
// Package agents defines the Agent interface and supporting types.
|
|
// Each agent (Auggie, Claude Code, Codex, etc.) implements this interface
|
|
// in its own file, consolidating identity, discovery, models, protocol,
|
|
// execution, and runtime configuration in one place.
|
|
package agents
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
|
"github.com/kandev/kandev/internal/agent/usage"
|
|
"github.com/kandev/kandev/internal/agentruntime"
|
|
"github.com/kandev/kandev/pkg/agent"
|
|
)
|
|
|
|
// ErrNotSupported is returned when an agent does not support an operation.
|
|
var ErrNotSupported = errors.New("not supported by this agent")
|
|
|
|
// Agent is the core interface for all coding agents.
|
|
//
|
|
// Models are no longer part of this interface — they come from the host utility
|
|
// capability cache (which probes each ACP agent at boot to learn its models and
|
|
// modes via session/new). See internal/agent/hostutility.
|
|
type Agent interface {
|
|
// --- Identity ---
|
|
ID() string
|
|
Name() string
|
|
DisplayName() string
|
|
Description() string
|
|
Enabled() bool
|
|
DisplayOrder() int // lower = higher priority in listings
|
|
|
|
// --- Assets ---
|
|
Logo(variant LogoVariant) []byte // nil if unavailable
|
|
|
|
// --- Discovery ---
|
|
IsInstalled(ctx context.Context) (*DiscoveryResult, error)
|
|
|
|
// --- Execution ---
|
|
BuildCommand(opts CommandOptions) Command
|
|
|
|
// --- Permissions ---
|
|
PermissionSettings() map[string]PermissionSetting
|
|
|
|
// --- Runtime ---
|
|
Runtime() *RuntimeConfig
|
|
|
|
// --- Billing ---
|
|
// BillingType returns how this agent is billed: api_key or subscription.
|
|
// Computed at call time from credential files, not stored in the DB.
|
|
BillingType() usage.BillingType
|
|
|
|
// --- Remote Auth ---
|
|
// RemoteAuth returns the auth methods this agent supports in remote environments.
|
|
// Returns nil if the agent has no remote auth configuration.
|
|
RemoteAuth() *RemoteAuth
|
|
|
|
// --- Installation ---
|
|
// InstallScript returns shell commands to pre-install the agent CLI in remote environments.
|
|
// Returns empty string if no installation is needed.
|
|
InstallScript() string
|
|
}
|
|
|
|
// InferenceAgent is an optional capability marker for agents that support
|
|
// one-shot LLM inference via the host utility manager. The actual model list
|
|
// is populated dynamically from the ACP probe — agents no longer declare a
|
|
// static model list.
|
|
type InferenceAgent interface {
|
|
// InferenceConfig returns the configuration for one-shot inference.
|
|
InferenceConfig() *InferenceConfig
|
|
}
|
|
|
|
// PassthroughAgent is an optional capability for agents that support CLI passthrough mode.
|
|
type PassthroughAgent interface {
|
|
PassthroughConfig() PassthroughConfig
|
|
BuildPassthroughCommand(opts PassthroughOptions) Command
|
|
}
|
|
|
|
// NativeBinaryAgent is an optional capability for agents whose npm package also
|
|
// ships a standalone CLI binary that behaves identically to `npx -y <pkg>`.
|
|
// When that binary is present in the execution environment, the lifecycle
|
|
// prefers it (BuildCommand receives CommandOptions.PreferNativeBinary=true) to
|
|
// skip the per-launch npm registry round-trip — which is slow behind a private
|
|
// registry. Containerized runtimes ship a controlled image and keep npx.
|
|
type NativeBinaryAgent interface {
|
|
// NativeBinaryName is the executable to look for on PATH (e.g. "copilot").
|
|
// An empty string disables native-binary preference.
|
|
NativeBinaryName() string
|
|
}
|
|
|
|
// LoginCommand describes an interactive CLI command for authenticating with
|
|
// an agent. The kandev backend runs this under a PTY so the UI can render a
|
|
// terminal and route keystrokes to the underlying process.
|
|
type LoginCommand struct {
|
|
// Cmd is the command + args to spawn, e.g. []string{"claude", "auth", "login"}.
|
|
Cmd []string
|
|
// Description renders above the terminal as a one-line hint, e.g.
|
|
// "Authenticate with your Anthropic account."
|
|
Description string
|
|
}
|
|
|
|
// LoginAgent is an optional capability for agents that need an interactive
|
|
// login flow (browser OAuth callback, token prompt, etc.). Implement it to
|
|
// surface a "Login" button in the UI that opens a PTY-backed terminal running
|
|
// LoginCommand().
|
|
type LoginAgent interface {
|
|
LoginCommand() *LoginCommand
|
|
}
|
|
|
|
// IsPassthroughOnly returns true if the agent only supports passthrough mode
|
|
// and should not have interactive MCP tools (e.g. ask_user_question) registered.
|
|
func IsPassthroughOnly(a Agent) bool {
|
|
_, ok := a.(*TUIAgent)
|
|
return ok
|
|
}
|
|
|
|
// LogoVariant selects light or dark logo.
|
|
type LogoVariant int
|
|
|
|
const (
|
|
LogoLight LogoVariant = iota
|
|
LogoDark
|
|
)
|
|
|
|
// DiscoveryResult is the result of checking if an agent is installed.
|
|
type DiscoveryResult struct {
|
|
Available bool
|
|
MatchedPath string
|
|
SupportsMCP bool
|
|
MCPConfigPaths []string
|
|
InstallationPaths []string
|
|
Capabilities DiscoveryCapabilities
|
|
}
|
|
|
|
// DiscoveryCapabilities describes what the agent supports.
|
|
type DiscoveryCapabilities struct {
|
|
SupportsSessionResume bool
|
|
SupportsShell bool
|
|
SupportsWorkspaceOnly bool
|
|
}
|
|
|
|
// CommandOptions are passed to BuildCommand.
|
|
type CommandOptions struct {
|
|
Model string
|
|
SessionID string // for --resume flag
|
|
ResumeAtMessageUUID string // for --resume-session-at flag (truncate conversation)
|
|
AutoApprove bool
|
|
PermissionPolicy string // "autonomous", "supervised", "plan"
|
|
PermissionValues map[string]bool // e.g. {"allow_indexing": true}
|
|
AgentType string // for --agent flag (e.g. "task" for subagent)
|
|
// CLIFlagTokens are user-configured CLI flag argv tokens derived from
|
|
// AgentProfile.CLIFlags (only Enabled entries, shell-tokenised). Appended
|
|
// verbatim by lifecycle.CommandBuilder after the agent builds its command.
|
|
CLIFlagTokens []string
|
|
// CommandPrefixTokens are user-configured launcher tokens derived from
|
|
// AgentProfile.CommandPrefix (shell-tokenised). Prepended to the built
|
|
// command so the agent subprocess runs wrapped in a sandbox launcher
|
|
// (e.g. ["greywall", "--"] → "greywall -- npx -y <pkg> …").
|
|
CommandPrefixTokens []string
|
|
// Runtime is the execution backend hosting the agent subprocess.
|
|
// Agents whose binary lives in a different place inside a container
|
|
// than on the host (currently only MockAgent) consult
|
|
// Runtime.IsContainerized() to pick a host absolute path vs. a bare
|
|
// name resolved via the container's PATH.
|
|
Runtime agentruntime.Runtime
|
|
// PreferNativeBinary is set by the lifecycle when a NativeBinaryAgent's
|
|
// standalone CLI was found in the execution environment. Such agents emit
|
|
// the native binary (e.g. "copilot --acp") instead of "npx -y <pkg>".
|
|
PreferNativeBinary bool
|
|
}
|
|
|
|
// PassthroughOptions are passed to BuildPassthroughCommand.
|
|
type PassthroughOptions struct {
|
|
Model string
|
|
SessionID string // ACP session ID; resumes a specific session via --resume <id>
|
|
Prompt string // initial prompt for new sessions
|
|
Resume bool // generic "continue last session" (e.g. -c, --resume latest)
|
|
PermissionValues map[string]bool // e.g. {"auto_approve": true}
|
|
WorkDir string
|
|
// MCPArgs are extra argv tokens produced by the agent's MCP passthrough
|
|
// strategy (e.g. claude's "--mcp-config <path>", codex's repeated "-c
|
|
// mcp_servers.…" overrides). Appended to the built command.
|
|
MCPArgs []string
|
|
// CLIFlagTokens are user-configured CLI flag argv tokens derived from
|
|
// AgentProfile.CLIFlags (only Enabled entries, shell-tokenised). Unlike ACP
|
|
// commands, which lifecycle.CommandBuilder appends centrally, passthrough
|
|
// agents opt in by appending these tokens in BuildPassthroughCommand.
|
|
CLIFlagTokens []string
|
|
}
|
|
|
|
// RuntimeConfig holds Docker / standalone runtime settings.
|
|
type RuntimeConfig struct {
|
|
Image string
|
|
Tag string
|
|
Cmd Command
|
|
Entrypoint Command
|
|
WorkingDir string
|
|
Env map[string]string
|
|
RequiredEnv []string
|
|
Mounts []MountTemplate
|
|
ResourceLimits ResourceLimits
|
|
SessionConfig SessionConfig
|
|
Protocol agent.Protocol
|
|
ModelFlag Param // e.g. NewParam("--model", "{model}")
|
|
WorkspaceFlag string // e.g. "--workspace-root"
|
|
AssumeMcpSse bool // Override: assume agent supports SSE MCP servers even if not advertised
|
|
AssumeMcpHttp bool // Override: assume agent supports HTTP MCP servers even if not advertised
|
|
ProjectSkillDir string // CWD-relative path for project-level skills (e.g. ".claude/skills")
|
|
UserSkillDir string // home-relative path for user-level skills (e.g. ".claude/skills")
|
|
// ProjectMCPStrategy materializes resolved MCP servers into a project-local
|
|
// config file before a protocol-mode agent subprocess starts. Use this for
|
|
// agents whose ACP adapter does not wire session/new mcpServers through to
|
|
// the underlying CLI.
|
|
ProjectMCPStrategy mcpconfig.PassthroughMCPStrategy
|
|
// RequiresProcessKill is true for agents whose subprocess should skip the
|
|
// graceful stdin-close wait because it is known not to exit on EOF (e.g.
|
|
// OpenCode's ACP runtime, which keeps its HTTP server and MCP child tree
|
|
// alive). Agentctl still reaps the process group for every agent after
|
|
// graceful shutdown or timeout; this flag only makes that reap immediate.
|
|
RequiresProcessKill bool
|
|
// StripEnv lists environment variables to remove from the agent's child
|
|
// process environment entirely (not just set to empty). Some programs
|
|
// distinguish unset from empty string and change behavior based on
|
|
// presence rather than value. The process manager strips these from the
|
|
// final child env after adapter merge; the inference executor strips
|
|
// them from the one-shot probe/inference subprocess env.
|
|
StripEnv []string
|
|
}
|
|
|
|
// MountTemplate defines a mount with template variables.
|
|
type MountTemplate struct {
|
|
Source string `json:"source"`
|
|
Target string `json:"target"`
|
|
ReadOnly bool `json:"read_only"`
|
|
}
|
|
|
|
// ResourceLimits defines resource constraints.
|
|
type ResourceLimits struct {
|
|
MemoryMB int64 `json:"memory_mb"`
|
|
CPUCores float64 `json:"cpu_cores"`
|
|
Timeout time.Duration `json:"timeout"`
|
|
}
|
|
|
|
// SessionConfig defines session resumption behaviour.
|
|
type SessionConfig struct {
|
|
NativeSessionResume bool
|
|
HistoryContextInjection bool // Opt-in: inject conversation history on session resume for agents without native resume
|
|
// NewSessionOnWorkspaceRebind forces session/new when an idle execution's
|
|
// working directory changes. Some providers accept session/load with a new
|
|
// cwd but keep tool execution pinned to the session's original directory.
|
|
NewSessionOnWorkspaceRebind bool
|
|
ResumeFlag Param
|
|
CanRecover *bool
|
|
SessionDirTemplate string
|
|
SessionDirTarget string
|
|
ForkSessionCmd Command
|
|
ContinueSessionCmd Command
|
|
}
|
|
|
|
// SupportsRecovery returns whether the agent supports session recovery.
|
|
// Returns true by default if CanRecover is not explicitly set.
|
|
func (c SessionConfig) SupportsRecovery() bool {
|
|
if c.CanRecover == nil {
|
|
return true
|
|
}
|
|
return *c.CanRecover
|
|
}
|
|
|
|
// PermissionApplyMethodCLIFlag is the PermissionSetting.ApplyMethod sentinel
|
|
// for permission flags that map to a CLI argument on the agent subprocess.
|
|
// A typo in any one caller (e.g. "cli-flag") silently breaks the seed and
|
|
// filter chain across agents, so the literal lives in exactly one place.
|
|
const PermissionApplyMethodCLIFlag = "cli_flag"
|
|
|
|
// PermissionApplyMethodAgentctlAutoApprove maps the profile auto_approve column
|
|
// to AGENTCTL_AUTO_APPROVE_PERMISSIONS at launch (all ACP agents).
|
|
const PermissionApplyMethodAgentctlAutoApprove = "agentctl_auto_approve"
|
|
|
|
// PermissionKeyAutoApprove is the PermissionSettings map key wired to the
|
|
// profile "Auto approve" toggle (see PermissionValues in buildAgentCommand).
|
|
// Centralised for the same reason as PermissionApplyMethodCLIFlag: a typo in
|
|
// any one agent silently disables its auto-approve flag.
|
|
const PermissionKeyAutoApprove = "auto_approve"
|
|
|
|
// PermissionKeyCursorForce is the Cursor-specific CLI --force toggle (unsandboxed
|
|
// run-everything). Separate from PermissionKeyAutoApprove (agentctl ACP approval).
|
|
const PermissionKeyCursorForce = "cursor_force"
|
|
|
|
// PermissionKeyDangerouslySkipPermissions is wired to the profile's
|
|
// DangerouslySkipPermissions column (a dedicated bool, not the cli_flags list).
|
|
// Agents whose CLI accepts --dangerously-skip-permissions (or equivalent)
|
|
// declare a PermissionSetting under this key with ApplyMethod=cli_flag so the
|
|
// passthrough Settings() pass emits the flag from the profile bool. The
|
|
// catalog seeder excludes this key to avoid duplicating the toggle into the
|
|
// curated cli_flags list (which would also double-emit the flag at launch).
|
|
const PermissionKeyDangerouslySkipPermissions = "dangerously_skip_permissions"
|
|
|
|
// PermissionSetting defines metadata for a permission setting option.
|
|
type PermissionSetting struct {
|
|
Supported bool `json:"supported"`
|
|
Default bool `json:"default"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
ApplyMethod string `json:"apply_method,omitempty"`
|
|
CLIFlag string `json:"cli_flag,omitempty"`
|
|
CLIFlagValue string `json:"cli_flag_value,omitempty"`
|
|
}
|
|
|
|
// PassthroughConfig defines configuration for CLI passthrough mode.
|
|
type PassthroughConfig struct {
|
|
Supported bool
|
|
Label string
|
|
Description string
|
|
PassthroughCmd Command
|
|
ModelFlag Param
|
|
PromptFlag Param
|
|
PromptPattern string
|
|
IdleTimeout time.Duration
|
|
BufferMaxBytes int64
|
|
StatusDetector string
|
|
CheckInterval time.Duration
|
|
StabilityWindow time.Duration
|
|
ResumeFlag Param // generic "continue last session" (e.g. NewParam("-c"), NewParam("--resume", "latest"))
|
|
SessionResumeFlag Param // resume a specific session by ID (e.g. NewParam("--resume"))
|
|
// MCPStrategy materializes resolved MCP servers into this CLI's passthrough
|
|
// shape (config file + flag, repeated -c overrides, project file, or env var)
|
|
// without touching the user's global config. Nil means no MCP injection.
|
|
MCPStrategy mcpconfig.PassthroughMCPStrategy
|
|
WaitForTerminal bool
|
|
// AutoInjectPrompt enables writing the task description to the PTY stdin
|
|
// after the first idle window. Default false preserves today's behavior.
|
|
AutoInjectPrompt bool
|
|
// SubmitSequence is appended after the prompt text when auto-injecting
|
|
// and when routing chat-compose messages to the PTY. "\r" for most TUIs.
|
|
// Empty inherits DefaultPassthroughSubmitSequence at PTY write sites.
|
|
SubmitSequence string
|
|
// DisableBracketedPaste sends prompt bytes verbatim (plus SubmitSequence).
|
|
// Claude Code enables bracketed-paste *mode* (?2004h) in its Ink TUI; injecting
|
|
// ESC[200~…ESC[201~ delimiters breaks input (nothing appears in the prompt).
|
|
DisableBracketedPaste bool
|
|
// SubmitDelay is the wait inserted before each non-first chunk when writing the
|
|
// prompt+submit sequence to PTY stdin. Ink-based TUIs (Claude Code) detect a
|
|
// "paste burst" when many stdin bytes arrive in one read and absorb the
|
|
// trailing \r into the pasted content instead of dispatching it as Enter.
|
|
// Splitting the prompt body from the submit byte with a small delay forces the
|
|
// submit to arrive as a discrete keystroke. 0 disables (other TUIs handle one
|
|
// atomic write fine).
|
|
SubmitDelay time.Duration
|
|
}
|
|
|
|
// DefaultBufferMaxBytes is the default maximum buffer size for passthrough mode (2 MB).
|
|
const DefaultBufferMaxBytes int64 = 2 * 1024 * 1024
|
|
|
|
// DefaultResourceLimits is the standard resource limit set shared by most agents.
|
|
var DefaultResourceLimits = ResourceLimits{
|
|
MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour,
|
|
}
|
|
|
|
// DefaultProjectSkillDir is the fallback CWD-relative skill directory used when
|
|
// an agent type does not declare a ProjectSkillDir in its RuntimeConfig.
|
|
const DefaultProjectSkillDir = ".agents/skills"
|
|
|
|
// ProjectSkillDirFromRuntime returns the CWD-relative skill directory for the
|
|
// agent. Falls back to DefaultProjectSkillDir if the agent's RuntimeConfig does
|
|
// not set one.
|
|
func ProjectSkillDirFromRuntime(a Agent) string {
|
|
if rt := a.Runtime(); rt != nil && rt.ProjectSkillDir != "" {
|
|
return rt.ProjectSkillDir
|
|
}
|
|
return DefaultProjectSkillDir
|
|
}
|
|
|
|
// UserSkillDirFromRuntime returns the home-relative user skill directory for the
|
|
// agent. An empty string means the agent does not expose a user-home skill dir.
|
|
func UserSkillDirFromRuntime(a Agent) string {
|
|
if rt := a.Runtime(); rt != nil {
|
|
return rt.UserSkillDir
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// InferenceConfig describes how an agent executes one-shot prompts via ACP.
|
|
type InferenceConfig struct {
|
|
// Supported indicates the agent can do one-shot inference.
|
|
Supported bool
|
|
// Command is the ACP command for one-shot inference.
|
|
// e.g., ["npx", "-y", "@agentclientprotocol/claude-agent-acp"]
|
|
Command Command
|
|
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
|
|
ModelFlag Param
|
|
}
|
|
|
|
// InferenceModel describes a model available for inference.
|
|
type InferenceModel struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
// RemoteAuth describes all auth methods an agent supports for remote environments.
|
|
type RemoteAuth struct {
|
|
Methods []RemoteAuthMethod `json:"methods"`
|
|
}
|
|
|
|
// RemoteAuthMethod describes one way an agent can authenticate in a remote environment.
|
|
type RemoteAuthMethod struct {
|
|
// Type is "env" (set env var via secret) or "files" (copy local files to remote).
|
|
Type string `json:"type"`
|
|
// EnvVar is the environment variable name (for type="env").
|
|
EnvVar string `json:"env_var,omitempty"`
|
|
// SetupHint is a UI hint for the user (for type="env").
|
|
SetupHint string `json:"setup_hint,omitempty"`
|
|
// SourceFiles maps OS name to relative paths from home dir (for type="files").
|
|
// Keys: "darwin", "linux", "windows".
|
|
SourceFiles map[string][]string `json:"source_files,omitempty"`
|
|
// TargetRelDir is the target directory relative to the remote user home (for type="files").
|
|
TargetRelDir string `json:"target_rel_dir,omitempty"`
|
|
// Label is a UI label for the file copy option (for type="files").
|
|
Label string `json:"label,omitempty"`
|
|
// SetupScript is an optional shell script that runs on the remote after the
|
|
// env var is resolved. Used to bootstrap credential files from env vars.
|
|
// Only meaningful for type="env". Can reference the env var by name.
|
|
SetupScript string `json:"setup_script,omitempty"`
|
|
}
|
|
|
|
// Command is a domain value type representing a CLI command with arguments.
|
|
// Serialize to []string only at system boundaries (process exec, Docker API, JSON DTOs).
|
|
type Command struct {
|
|
args []string
|
|
}
|
|
|
|
// NewCommand creates a Command from the given arguments.
|
|
func NewCommand(args ...string) Command {
|
|
return Command{args: append([]string{}, args...)}
|
|
}
|
|
|
|
// Args returns the raw string slice for serialization at system boundaries.
|
|
func (c Command) Args() []string {
|
|
return c.args
|
|
}
|
|
|
|
// IsEmpty reports whether the command has no arguments.
|
|
func (c Command) IsEmpty() bool {
|
|
return len(c.args) == 0
|
|
}
|
|
|
|
// With returns a CmdBuilder seeded with this command's arguments,
|
|
// allowing fluent extension without mutating the original.
|
|
func (c Command) With() *CmdBuilder {
|
|
return &CmdBuilder{args: append([]string{}, c.args...)}
|
|
}
|
|
|
|
// Param is a command fragment — one or more pre-split CLI arguments
|
|
// (flags, flag+value pairs, templates with placeholders).
|
|
// Composed into a Command via CmdBuilder methods.
|
|
type Param struct {
|
|
args []string
|
|
}
|
|
|
|
// NewParam creates a Param from the given arguments.
|
|
func NewParam(args ...string) Param {
|
|
return Param{args: append([]string{}, args...)}
|
|
}
|
|
|
|
// Args returns the raw string slice.
|
|
func (p Param) Args() []string { return p.args }
|
|
|
|
// IsEmpty reports whether the param has no arguments.
|
|
func (p Param) IsEmpty() bool { return len(p.args) == 0 }
|