From a639c2919d9fd24d8f34b4c09b54bf56a0ac16b3 Mon Sep 17 00:00:00 2001 From: Greg Born Date: Wed, 18 Mar 2026 10:03:38 +0000 Subject: [PATCH 1/3] feat: add instance ID to session search filter for notification deep-linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilterByQuery now matches against inst.ID, enabling the TUI's `/` search to find sessions by their agent-deck instance ID (e.g., "be51eb02"). This is the Go-side piece of the click-to-resume notification flow: notify.sh embeds ?ad= in the focusterminal:// URL, the AHK handler focuses the Agent Deck tab and types the ID into search. Created by gregb and his home-grown crew of builders 🦜 🤖 --- internal/session/discovery.go | 3 ++- internal/session/discovery_test.go | 32 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 From 7e6bfdeec5b62765a45d31e868b3fdaa659bdb59 Mon Sep 17 00:00:00 2001 From: Greg Born Date: Fri, 20 Mar 2026 04:59:33 +0000 Subject: [PATCH 2/3] Fix wrapper not applying to compound shell commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session has a wrapper configured (e.g., "osc8-shorten {command}"), the naive {command} substitution produced broken shell commands because compound expressions (with &&, ;) caused the wrapper to only wrap the first segment. For example: osc8-shorten export FOO=bar && exec claude --session-id "..." Only "osc8-shorten export FOO=bar" ran through the wrapper; "exec claude" ran separately, unwrapped. Fix: prepareCommand() now pre-wraps compound commands in bash -c before applying the wrapper, producing: osc8-shorten bash -c 'export FOO=bar && exec claude --session-id "..."' The tmux Start() method skips its own bash -c wrapping (via SkipBashCWrap flag) to avoid double-wrapping. Also: - Add Wrapper field to ClaudeSettings so [claude].wrapper in config.toml propagates to new sessions automatically - New sessions inherit wrapper from [claude] config when no explicit wrapper is provided via --wrapper flag Created by gregb and his home-grown crew of builders 🦜 🤖 --- cmd/agent-deck/launch_cmd.go | 7 +++++++ cmd/agent-deck/main.go | 8 +++++++- internal/session/instance.go | 32 ++++++++++++++++++++++++++++++++ internal/session/userconfig.go | 6 ++++++ internal/tmux/tmux.go | 9 ++++++++- 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/cmd/agent-deck/launch_cmd.go b/cmd/agent-deck/launch_cmd.go index d0aa10fa2..709adb225 100644 --- a/cmd/agent-deck/launch_cmd.go +++ b/cmd/agent-deck/launch_cmd.go @@ -272,6 +272,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 f8f21c6f9..9f5269bae 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -1134,9 +1134,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/instance.go b/internal/session/instance.go index 4d74c9892..ef2628d03 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -1793,6 +1793,25 @@ 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 +} + +// commandNeedsBashCWrap returns true if the command is a compound shell expression +// that needs bash -c wrapping to be passed as a single unit to a wrapper executable. +func commandNeedsBashCWrap(cmd string) bool { + return strings.Contains(cmd, "$(") || strings.Contains(cmd, "&&") || + strings.Contains(cmd, "; ") || strings.Contains(cmd, "session_id=") +} + // 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. @@ -4949,6 +4968,19 @@ 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) { + // When a wrapper is configured and the command is a compound shell expression + // (contains &&, ;, etc.), pre-wrap the command in bash -c so the wrapper + // executable receives a single atomic command. Without this, shell operators + // would cause the wrapper to only apply to the first segment. + // We also tell tmux to skip its own bash -c wrapping to avoid double-wrapping. + if i.hasEffectiveWrapper() && commandNeedsBashCWrap(cmd) { + 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 6da112555..b4738e75d 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -553,6 +553,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 583f3390b..35d20fe05 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 @@ -1283,7 +1288,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) } From d9ea15712c8a85d54db4c4f931508493684eb6c6 Mon Sep 17 00:00:00 2001 From: Greg Born Date: Wed, 25 Mar 2026 02:24:31 +0000 Subject: [PATCH 3/3] fix: always bash-c wrap commands when wrapper is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a wrapper is set in config.toml (e.g. wrapper = "direnv exec ."), wrappers use execvp() which cannot interpret shell syntax. The previous conditional check (commandNeedsBashCWrap) missed cases where inline env var prefixes like AGENTDECK_INSTANCE_ID=xxx had no accompanying shell operators (&&, $(), etc.), causing them to be passed as literal argv to the wrapper instead of being interpreted by the shell. Fix: wrap unconditionally when any wrapper is configured, remove the now-dead commandNeedsBashCWrap function. Created by gregb and his home-grown crew of builders 🦜 🤖 --- internal/session/instance.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/internal/session/instance.go b/internal/session/instance.go index 649526aa2..c07823e8f 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -1843,13 +1843,6 @@ func (i *Instance) hasEffectiveWrapper() bool { return false } -// commandNeedsBashCWrap returns true if the command is a compound shell expression -// that needs bash -c wrapping to be passed as a single unit to a wrapper executable. -func commandNeedsBashCWrap(cmd string) bool { - return strings.Contains(cmd, "$(") || strings.Contains(cmd, "&&") || - strings.Contains(cmd, "; ") || strings.Contains(cmd, "session_id=") -} - // 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. @@ -5010,12 +5003,13 @@ 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) { - // When a wrapper is configured and the command is a compound shell expression - // (contains &&, ;, etc.), pre-wrap the command in bash -c so the wrapper - // executable receives a single atomic command. Without this, shell operators - // would cause the wrapper to only apply to the first segment. - // We also tell tmux to skip its own bash -c wrapping to avoid double-wrapping. - if i.hasEffectiveWrapper() && commandNeedsBashCWrap(cmd) { + // 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 {