diff --git a/cmd/agent-deck/launch_cmd.go b/cmd/agent-deck/launch_cmd.go index 13d58b746..d6cc82f4b 100644 --- a/cmd/agent-deck/launch_cmd.go +++ b/cmd/agent-deck/launch_cmd.go @@ -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 != "" { diff --git a/cmd/agent-deck/main.go b/cmd/agent-deck/main.go index e993c5504..2f99458ad 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -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 diff --git a/internal/session/discovery.go b/internal/session/discovery.go index bd71d80da..3ecd49940 100644 --- a/internal/session/discovery.go +++ b/internal/session/discovery.go @@ -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) } } diff --git a/internal/session/discovery_test.go b/internal/session/discovery_test.go index c2d576c00..902b2a866 100644 --- a/internal/session/discovery_test.go +++ b/internal/session/discovery_test.go @@ -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 diff --git a/internal/session/instance.go b/internal/session/instance.go index 30c9fc6a5..c07823e8f 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -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. @@ -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 diff --git a/internal/session/userconfig.go b/internal/session/userconfig.go index 86589a767..bee89e625 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -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. diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 3374e7c24..34de0fd7b 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -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 @@ -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) }