Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/agent-deck/launch_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@ func handleLaunch(profile string, args []string) {

if sessionWrapperResolved != "" {
newInstance.Wrapper = sessionWrapperResolved
} else if newInstance.Wrapper == "" {
// Fall back to tool-level wrapper from config.toml (e.g. [claude].wrapper)
if cfg, err := session.LoadUserConfig(); err == nil && cfg != nil {
if session.IsClaudeCompatible(newInstance.Tool) && cfg.Claude.Wrapper != "" {
newInstance.Wrapper = cfg.Claude.Wrapper
}
}
}

if worktreePath != "" {
Expand Down
8 changes: 7 additions & 1 deletion cmd/agent-deck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1123,9 +1123,15 @@ func handleAdd(profile string, args []string) {
newInstance.Command = sessionCommandResolved
}

// Set wrapper if provided
// Set wrapper if provided, or fall back to tool-level config
if sessionWrapperResolved != "" {
newInstance.Wrapper = sessionWrapperResolved
} else if newInstance.Wrapper == "" {
if cfg, err := session.LoadUserConfig(); err == nil && cfg != nil {
if session.IsClaudeCompatible(newInstance.Tool) && cfg.Claude.Wrapper != "" {
newInstance.Wrapper = cfg.Claude.Wrapper
}
}
}

// Set worktree fields if created
Expand Down
3 changes: 2 additions & 1 deletion internal/session/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ func FilterByQuery(instances []*Instance, query string) []*Instance {
for _, inst := range instances {
if strings.Contains(strings.ToLower(inst.Title), query) ||
strings.Contains(strings.ToLower(inst.ProjectPath), query) ||
strings.Contains(strings.ToLower(inst.Tool), query) {
strings.Contains(strings.ToLower(inst.Tool), query) ||
strings.Contains(strings.ToLower(inst.ID), query) {
filtered = append(filtered, inst)
}
}
Expand Down
32 changes: 32 additions & 0 deletions internal/session/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ func TestFilterByQueryCaseInsensitive(t *testing.T) {
}
}

func TestFilterByQueryByInstanceID(t *testing.T) {
instances := []*Instance{
{ID: "abc12345-1710777600", Title: "session-one", ProjectPath: "/tmp", Tool: "claude"},
{ID: "def67890-1710777601", Title: "session-two", ProjectPath: "/tmp", Tool: "shell"},
{ID: "abc12345-1710777602", Title: "session-three", ProjectPath: "/home", Tool: "gemini"},
}

// Full hex prefix match
result := FilterByQuery(instances, "def67890")
if len(result) != 1 || result[0].Title != "session-two" {
t.Errorf("Expected 1 result for 'def67890', got %d", len(result))
}

// Partial ID prefix match
result = FilterByQuery(instances, "abc12345")
if len(result) != 2 {
t.Errorf("Expected 2 results for 'abc12345' (shared prefix), got %d", len(result))
}

// Full ID match (hex + timestamp)
result = FilterByQuery(instances, "abc12345-1710777600")
if len(result) != 1 || result[0].Title != "session-one" {
t.Errorf("Expected 1 result for full ID, got %d", len(result))
}

// No match
result = FilterByQuery(instances, "zzz99999")
if len(result) != 0 {
t.Errorf("Expected 0 results for 'zzz99999', got %d", len(result))
}
}

func TestDetectToolFromName(t *testing.T) {
tests := []struct {
name string
Expand Down
26 changes: 26 additions & 0 deletions internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,18 @@ func (i *Instance) applyWrapper(command string) (string, error) {
return wrapper, nil
}

// hasEffectiveWrapper returns true if the instance has a wrapper configured,
// either directly on the instance or via the tool definition in config.toml.
func (i *Instance) hasEffectiveWrapper() bool {
if i.Wrapper != "" {
return true
}
if toolDef := GetToolDef(i.Tool); toolDef != nil && toolDef.Wrapper != "" {
return true
}
return false
}

// loadCustomPatternsFromConfig loads detection patterns from built-in defaults + config.toml
// overrides, and sets them on the tmux session for status detection and tool auto-detection.
// Works for ALL tools: built-in (claude, gemini, opencode, codex) and custom.
Expand Down Expand Up @@ -4991,6 +5003,20 @@ func (i *Instance) wrapForSandbox(command string) (string, string, error) {
// All code paths that launch or respawn a tmux pane should use this instead of calling
// applyWrapper/wrapForSandbox/wrapIgnoreSuspend individually.
func (i *Instance) prepareCommand(cmd string) (string, string, error) {
// Always pre-wrap in bash -c when a wrapper is configured. Wrappers use
// execvp() which cannot interpret shell syntax, so without this any
// metacharacter in cmd (inline env vars, &&, $(), etc.) would be passed
// as literal argv. Wrapping unconditionally is both safe and simpler than
// trying to detect which commands need it. Also suppress tmux's own
// bash -c wrap to avoid double-wrapping.
if i.hasEffectiveWrapper() {
escaped := strings.ReplaceAll(cmd, "'", "'\"'\"'")
cmd = fmt.Sprintf("bash -c '%s'", escaped)
if i.tmuxSession != nil {
i.tmuxSession.SkipBashCWrap = true
}
}

wrapped, err := i.applyWrapper(cmd)
if err != nil {
return "", "", err
Expand Down
6 changes: 6 additions & 0 deletions internal/session/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,12 @@ type ClaudeSettings struct {
// for instant, deterministic status updates instead of polling tmux content.
// Default: true (nil = use default true, set false to disable)
HooksEnabled *bool `toml:"hooks_enabled"`

// Wrapper is an optional command that wraps the Claude process.
// Use {command} placeholder to include the tool command.
// Applied as default wrapper for new Claude sessions (can be overridden per-session).
// Example: wrapper = "osc8-shorten {command}"
Wrapper string `toml:"wrapper"`
}

// GetProfileClaudeConfigDir returns the profile-specific Claude config directory, if configured.
Expand Down
9 changes: 8 additions & 1 deletion internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,11 @@ type Session struct {
// Sandbox sessions enable this so pane-dead detection can restart exited tools.
RunCommandAsInitialProcess bool

// SkipBashCWrap tells Start() to skip the automatic bash -c wrapping.
// Set by prepareCommand when a wrapper pre-wraps the compound command,
// preventing double-wrapping that would break nested quoting.
SkipBashCWrap bool

// Custom patterns for generic tool support
customToolName string
customBusyPatterns []string
Expand Down Expand Up @@ -1292,7 +1297,9 @@ func (s *Session) Start(command string) error {
if command != "" && !startWithInitialProcess {
cmdToSend := command
// Commands containing bash-specific syntax must be wrapped for fish users.
if strings.Contains(command, "$(") || strings.Contains(command, "session_id=") {
// Skip if SkipBashCWrap is set (command was pre-wrapped by prepareCommand
// to support user wrappers without double-wrapping).
if !s.SkipBashCWrap && (strings.Contains(command, "$(") || strings.Contains(command, "session_id=")) {
escapedCmd := strings.ReplaceAll(command, "'", "'\"'\"'")
cmdToSend = fmt.Sprintf("bash -c '%s'", escapedCmd)
}
Expand Down