diff --git a/go.mod b/go.mod index 378e0486af06e8d46368e1e881b8f2f4dc35f243..9cfa71a03a29d6349e67a4a7b4aca24a657b04c3 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/charmbracelet/catwalk v0.8.1 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 + github.com/charmbracelet/hotdiva2000 v0.0.0-20250826160440-48bbea31f5a8 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 github.com/charmbracelet/x/ansi v0.10.2 diff --git a/go.sum b/go.sum index c2d0cb22c9b360da4c5c31e86eec578b4e03b673..e961bafe8e259bd1a9e491d0e43906dd816e8a3b 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvp github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk= github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97 h1:HK7B5Q+0FidxjQD5CovniMw7axkUeMHwgVkxkbmiW/s= github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97/go.mod h1:ZagL2esO4qxlOJBj0d4PVvLM82akQFtne8s3ivxBnTQ= +github.com/charmbracelet/hotdiva2000 v0.0.0-20250826160440-48bbea31f5a8 h1:w1J++lBA+XTqFHvpoG2Vb3sMEfqw3yTxnAOmLVmcPio= +github.com/charmbracelet/hotdiva2000 v0.0.0-20250826160440-48bbea31f5a8/go.mod h1:9KGSzLBb3ARFkllODp0IGQBleFii+Awgzz9zT99tLQc= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE= diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 367a7e67a2ea7fdf732984aa8d5774bf04752a8a..b0822fdf55b7ec2922f604b86cd5fb448282ca36 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -331,6 +331,8 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan allTools = append(allTools, tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution), + tools.NewBashOutputTool(), + tools.NewBashKillTool(), tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), diff --git a/internal/agent/tools/bash.go b/internal/agent/tools/bash.go index e646e14d9aa4ee6f4690adeeb256678540c249c8..29b83fbaa99429c8fc2c03d58711479519d040f8 100644 --- a/internal/agent/tools/bash.go +++ b/internal/agent/tools/bash.go @@ -22,12 +22,14 @@ type BashParams struct { Command string `json:"command" description:"The command to execute"` Description string `json:"description,omitempty" description:"A brief description of what the command does"` Timeout int `json:"timeout,omitempty" description:"Optional timeout in milliseconds (max 600000)"` + Background bool `json:"background,omitempty" description:"Run the command in a background shell. Returns a shell ID for managing the process."` } type BashPermissionsParams struct { Command string `json:"command"` Description string `json:"description"` Timeout int `json:"timeout"` + Background bool `json:"background"` } type BashResponseMetadata struct { @@ -36,6 +38,8 @@ type BashResponseMetadata struct { Output string `json:"output"` Description string `json:"description"` WorkingDirectory string `json:"working_directory"` + Background bool `json:"background,omitempty"` + ShellID string `json:"shell_id,omitempty"` } const ( @@ -215,11 +219,16 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command") } if !isSafeReadOnly { - shell := shell.GetPersistentShell(workingDir) + var shellDir string + if params.Background { + shellDir = workingDir + } else { + shellDir = shell.GetPersistentShell(workingDir).GetWorkingDir() + } p := permissions.Request( permission.CreatePermissionRequest{ SessionID: sessionID, - Path: shell.GetWorkingDir(), + Path: shellDir, ToolCallID: call.ID, ToolName: BashToolName, Action: "execute", @@ -227,6 +236,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution Params: BashPermissionsParams{ Command: params.Command, Description: params.Description, + Background: params.Background, }, }, ) @@ -234,6 +244,27 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } } + + if params.Background { + startTime := time.Now() + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, blockFuncs(), params.Command) + if err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err) + } + + metadata := BashResponseMetadata{ + StartTime: startTime.UnixMilli(), + EndTime: time.Now().UnixMilli(), + Description: params.Description, + WorkingDirectory: bgShell.GetWorkingDir(), + Background: true, + ShellID: bgShell.ID, + } + response := fmt.Sprintf("Background shell started with ID: %s\n\nUse bash_output tool to view output or bash_kill to terminate.", bgShell.ID) + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil + } + startTime := time.Now() if params.Timeout > 0 { var cancel context.CancelFunc @@ -244,7 +275,6 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution persistentShell := shell.GetPersistentShell(workingDir) stdout, stderr, err := persistentShell.Exec(ctx, params.Command) - // Get the current working directory after command execution currentWorkingDir := persistentShell.GetWorkingDir() interrupted := shell.IsInterrupt(err) exitCode := shell.ExitCode(err) diff --git a/internal/agent/tools/bash.tpl b/internal/agent/tools/bash.tpl index 05b34517a0743c6c185734ac04b99677539c7307..59fee859ead966c9ffcbe7422b151ae33d30d9de 100644 --- a/internal/agent/tools/bash.tpl +++ b/internal/agent/tools/bash.tpl @@ -18,10 +18,19 @@ Common shell builtins and core utils available on Windows. - Command required, timeout optional (max 600000ms/10min, default 30min if unspecified) - IMPORTANT: Use Grep/Glob/Agent tools instead of 'find'/'grep'. Use View/LS tools instead of 'cat'/'head'/'tail'/'ls' - Chain with ';' or '&&', avoid newlines except in quoted strings -- Shell state persists (env vars, virtual envs, cwd, etc.) +- Shell state persists (env vars, virtual envs, cwd, etc.) unless running in background - Prefer absolute paths over 'cd' (use 'cd' only if user explicitly requests) + +- Set background=true to run commands in a separate background shell +- Background shells don't share state with the persistent shell +- Returns a shell ID for managing the background process +- Use bash_output tool to view current output from background shell +- Use bash_kill tool to terminate a background shell +- Useful for long-running processes, servers, or monitoring tasks + + When user asks to create git commit: diff --git a/internal/agent/tools/bash_background_test.go b/internal/agent/tools/bash_background_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2f0c0261b7b274eac9dde26f847d7b0a042b2768 --- /dev/null +++ b/internal/agent/tools/bash_background_test.go @@ -0,0 +1,357 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/charmbracelet/crush/internal/shell" + "github.com/stretchr/testify/require" +) + +func TestBackgroundShell_Integration(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a background shell + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'hello background' && echo 'done'") + require.NoError(t, err) + require.NotEmpty(t, bgShell.ID) + + // Wait for completion + bgShell.Wait() + + // Check final output + stdout, stderr, done, err := bgShell.GetOutput() + require.NoError(t, err) + require.Contains(t, stdout, "hello background") + require.Contains(t, stdout, "done") + require.True(t, done) + require.Empty(t, stderr) + + // Clean up + bgManager.Kill(bgShell.ID) +} + +func TestBackgroundShell_Kill(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a long-running background shell + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 100") + require.NoError(t, err) + + // Kill it + err = bgManager.Kill(bgShell.ID) + require.NoError(t, err) + + // Verify it's gone + _, ok := bgManager.Get(bgShell.ID) + require.False(t, ok) + + // Verify the shell is done + require.True(t, bgShell.IsDone()) +} + +func TestBackgroundShell_GetWorkingDir_NoHang(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a long-running background shell + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 10") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // This should complete quickly without hanging, even while the command is running + done := make(chan string, 1) + go func() { + dir := bgShell.GetWorkingDir() + done <- dir + }() + + select { + case dir := <-done: + require.Equal(t, workingDir, dir) + case <-time.After(2 * time.Second): + t.Fatal("GetWorkingDir() hung - did not complete within timeout") + } +} + +func TestBackgroundShell_GetOutput_NoHang(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a long-running background shell + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 10") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // This should complete quickly without hanging + done := make(chan struct{}) + go func() { + _, _, _, err := bgShell.GetOutput() + require.NoError(t, err) + close(done) + }() + + select { + case <-done: + // Success - didn't hang + case <-time.After(2 * time.Second): + t.Fatal("GetOutput() hung - did not complete within timeout") + } +} + +func TestBackgroundShell_MultipleOutputCalls(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a background shell + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'step 1' && echo 'step 2' && echo 'step 3'") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Check that we can call GetOutput multiple times while running + for range 5 { + _, _, done, _ := bgShell.GetOutput() + if done { + break + } + time.Sleep(10 * time.Millisecond) + } + + // Wait for completion + bgShell.Wait() + + // Multiple calls after completion should return the same result + stdout1, _, done1, _ := bgShell.GetOutput() + require.True(t, done1) + require.Contains(t, stdout1, "step 1") + require.Contains(t, stdout1, "step 2") + require.Contains(t, stdout1, "step 3") + + stdout2, _, done2, _ := bgShell.GetOutput() + require.True(t, done2) + require.Equal(t, stdout1, stdout2, "Multiple GetOutput calls should return same result") +} + +func TestBackgroundShell_EmptyOutput(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a background shell with no output + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 0.1") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Wait for completion + bgShell.Wait() + + stdout, stderr, done, err := bgShell.GetOutput() + require.NoError(t, err) + require.Empty(t, stdout) + require.Empty(t, stderr) + require.True(t, done) +} + +func TestBackgroundShell_ExitCode(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a background shell that exits with non-zero code + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'failing' && exit 42") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Wait for completion + bgShell.Wait() + + stdout, _, done, execErr := bgShell.GetOutput() + require.True(t, done) + require.Contains(t, stdout, "failing") + require.Error(t, execErr) + + exitCode := shell.ExitCode(execErr) + require.Equal(t, 42, exitCode) +} + +func TestBackgroundShell_WithBlockFuncs(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + blockFuncs := []shell.BlockFunc{ + shell.CommandsBlocker([]string{"curl", "wget"}), + } + + // Start a background shell with a blocked command + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, blockFuncs, "curl example.com") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Wait for completion + bgShell.Wait() + + stdout, stderr, done, execErr := bgShell.GetOutput() + require.True(t, done) + + // The command should have been blocked, check stderr or error + if execErr != nil { + // Error might contain the message + require.Contains(t, execErr.Error(), "not allowed") + } else { + // Or it might be in stderr + output := stdout + stderr + require.Contains(t, output, "not allowed") + } +} + +func TestBackgroundShell_StdoutAndStderr(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a background shell with both stdout and stderr + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'stdout message' && echo 'stderr message' >&2") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Wait for completion + bgShell.Wait() + + stdout, stderr, done, err := bgShell.GetOutput() + require.NoError(t, err) + require.True(t, done) + require.Contains(t, stdout, "stdout message") + require.Contains(t, stderr, "stderr message") +} + +func TestBackgroundShell_ConcurrentAccess(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + // Start a background shell + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "for i in 1 2 3 4 5; do echo \"line $i\"; sleep 0.05; done") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Access output concurrently from multiple goroutines + done := make(chan struct{}) + errors := make(chan error, 10) + + for range 10 { + go func() { + for { + select { + case <-done: + return + default: + _, _, _, err := bgShell.GetOutput() + if err != nil { + errors <- err + } + dir := bgShell.GetWorkingDir() + if dir == "" { + errors <- err + } + time.Sleep(10 * time.Millisecond) + } + } + }() + } + + // Let it run for a bit + time.Sleep(300 * time.Millisecond) + close(done) + + // Check for any errors + select { + case err := <-errors: + t.Fatalf("Concurrent access caused error: %v", err) + case <-time.After(100 * time.Millisecond): + // No errors - success + } +} + +func TestBackgroundShell_List(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + bgManager := shell.GetBackgroundShellManager() + + // Start multiple background shells + shells := make([]*shell.BackgroundShell, 3) + for i := range 3 { + bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 1") + require.NoError(t, err) + shells[i] = bgShell + } + + // Get the list + ids := bgManager.List() + + // Verify all our shells are in the list + for _, sh := range shells { + require.Contains(t, ids, sh.ID, "Shell %s not found in list", sh.ID) + } + + // Clean up + for _, sh := range shells { + bgManager.Kill(sh.ID) + } +} + +func TestBackgroundShell_IDFormat(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + ctx := context.Background() + + bgManager := shell.GetBackgroundShellManager() + bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'test'") + require.NoError(t, err) + defer bgManager.Kill(bgShell.ID) + + // Verify ID is human-readable (hotdiva2000 format) + // Should contain hyphens and be readable + require.NotEmpty(t, bgShell.ID) + require.Contains(t, bgShell.ID, "-", "ID should be human-readable with hyphens") + + // Should not be a UUID format + require.False(t, strings.Contains(bgShell.ID, "uuid"), "ID should not be UUID format") + + // Length should be reasonable for human-readable IDs + require.Greater(t, len(bgShell.ID), 5, "ID should be long enough") + require.Less(t, len(bgShell.ID), 100, "ID should not be too long") +} diff --git a/internal/agent/tools/bash_kill.go b/internal/agent/tools/bash_kill.go new file mode 100644 index 0000000000000000000000000000000000000000..e89d75655abdb8bb29d3c2a77520cf349e0c0b5e --- /dev/null +++ b/internal/agent/tools/bash_kill.go @@ -0,0 +1,63 @@ +package tools + +import ( + "context" + "fmt" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/shell" +) + +const ( + BashKillToolName = "bash_kill" +) + +type BashKillParams struct { + ShellID string `json:"shell_id" description:"The ID of the background shell to terminate"` +} + +type BashKillResponseMetadata struct { + ShellID string `json:"shell_id"` +} + +const bashKillDescription = `Terminates a background shell process. + + +- Provide the shell ID returned from a background bash execution +- Cancels the running process and cleans up resources + + + +- Stop long-running background processes +- Clean up completed background shells +- Immediately terminates the process + + + +- Use this when you need to stop a background process +- The process is terminated immediately (similar to SIGTERM) +- After killing, the shell ID becomes invalid + +` + +func NewBashKillTool() fantasy.AgentTool { + return fantasy.NewAgentTool( + BashKillToolName, + bashKillDescription, + func(ctx context.Context, params BashKillParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if params.ShellID == "" { + return fantasy.NewTextErrorResponse("missing shell_id"), nil + } + + bgManager := shell.GetBackgroundShellManager() + err := bgManager.Kill(params.ShellID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + metadata := BashKillResponseMetadata(params) + + result := fmt.Sprintf("Background shell %s terminated successfully", params.ShellID) + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), metadata), nil + }) +} diff --git a/internal/agent/tools/bash_output.go b/internal/agent/tools/bash_output.go new file mode 100644 index 0000000000000000000000000000000000000000..6fd6551ff9ed0f51dd98c243a89dc2aab5cd626b --- /dev/null +++ b/internal/agent/tools/bash_output.go @@ -0,0 +1,98 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/shell" +) + +const ( + BashOutputToolName = "bash_output" +) + +type BashOutputParams struct { + ShellID string `json:"shell_id" description:"The ID of the background shell to retrieve output from"` +} + +type BashOutputResponseMetadata struct { + ShellID string `json:"shell_id"` + Done bool `json:"done"` + WorkingDirectory string `json:"working_directory"` +} + +const bashOutputDescription = `Retrieves the current output from a background shell. + + +- Provide the shell ID returned from a background bash execution +- Returns the current stdout and stderr output +- Indicates whether the shell has completed execution + + + +- View output from running background processes +- Check if background process has completed +- Get cumulative output from process start + + + +- Use this to monitor long-running processes +- Check the 'done' status to see if process completed +- Can be called multiple times to view incremental output + +` + +func NewBashOutputTool() fantasy.AgentTool { + return fantasy.NewAgentTool( + BashOutputToolName, + bashOutputDescription, + func(ctx context.Context, params BashOutputParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if params.ShellID == "" { + return fantasy.NewTextErrorResponse("missing shell_id"), nil + } + + bgManager := shell.GetBackgroundShellManager() + bgShell, ok := bgManager.Get(params.ShellID) + if !ok { + return fantasy.NewTextErrorResponse(fmt.Sprintf("background shell not found: %s", params.ShellID)), nil + } + + stdout, stderr, done, err := bgShell.GetOutput() + + var outputParts []string + if stdout != "" { + outputParts = append(outputParts, stdout) + } + if stderr != "" { + outputParts = append(outputParts, stderr) + } + + status := "running" + if done { + status = "completed" + if err != nil { + exitCode := shell.ExitCode(err) + if exitCode != 0 { + outputParts = append(outputParts, fmt.Sprintf("Exit code %d", exitCode)) + } + } + } + + output := strings.Join(outputParts, "\n") + + metadata := BashOutputResponseMetadata{ + ShellID: params.ShellID, + Done: done, + WorkingDirectory: bgShell.GetWorkingDir(), + } + + if output == "" { + output = BashNoOutput + } + + result := fmt.Sprintf("Shell ID: %s\nStatus: %s\n\nOutput:\n%s", params.ShellID, status, output) + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), metadata), nil + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index acca8b62c8000e69a64a5fe41b4f756f2af1d8c4..876dd3d8c6ccc780d2add8c027a07ca5d3041b02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -469,6 +469,8 @@ func allToolNames() []string { return []string{ "agent", "bash", + "bash_output", + "bash_kill", "download", "edit", "multiedit", diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 06e4e664a49cc11d16516f83aaaf029e6f3144d8..16550baaf1561a0bca84f0cf0f861a6bd9038d82 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -485,7 +485,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "bash_output", "bash_kill", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) @@ -508,7 +508,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "bash_output", "bash_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) diff --git a/internal/shell/background.go b/internal/shell/background.go new file mode 100644 index 0000000000000000000000000000000000000000..d68567aba74e11c8418374acbd19efeb49d65fe1 --- /dev/null +++ b/internal/shell/background.go @@ -0,0 +1,157 @@ +package shell + +import ( + "bytes" + "context" + "fmt" + "sync" + + "github.com/charmbracelet/hotdiva2000" +) + +// BackgroundShell represents a shell running in the background. +type BackgroundShell struct { + ID string + Shell *Shell + ctx context.Context + cancel context.CancelFunc + stdout *bytes.Buffer + stderr *bytes.Buffer + mu sync.RWMutex + done chan struct{} + exitErr error + workingDir string +} + +// BackgroundShellManager manages background shell instances. +type BackgroundShellManager struct { + shells map[string]*BackgroundShell + mu sync.RWMutex +} + +var ( + backgroundManager *BackgroundShellManager + backgroundManagerOnce sync.Once +) + +// GetBackgroundShellManager returns the singleton background shell manager. +func GetBackgroundShellManager() *BackgroundShellManager { + backgroundManagerOnce.Do(func() { + backgroundManager = &BackgroundShellManager{ + shells: make(map[string]*BackgroundShell), + } + }) + return backgroundManager +} + +// Start creates and starts a new background shell with the given command. +func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, blockFuncs []BlockFunc, command string) (*BackgroundShell, error) { + id := hotdiva2000.Generate() + + shell := NewShell(&Options{ + WorkingDir: workingDir, + BlockFuncs: blockFuncs, + }) + + shellCtx, cancel := context.WithCancel(ctx) + + bgShell := &BackgroundShell{ + ID: id, + Shell: shell, + ctx: shellCtx, + cancel: cancel, + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + done: make(chan struct{}), + workingDir: workingDir, + } + + m.mu.Lock() + m.shells[id] = bgShell + m.mu.Unlock() + + go func() { + defer close(bgShell.done) + + stdout, stderr, err := shell.Exec(shellCtx, command) + + bgShell.mu.Lock() + bgShell.stdout.WriteString(stdout) + bgShell.stderr.WriteString(stderr) + bgShell.exitErr = err + bgShell.mu.Unlock() + }() + + return bgShell, nil +} + +// Get retrieves a background shell by ID. +func (m *BackgroundShellManager) Get(id string) (*BackgroundShell, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + shell, ok := m.shells[id] + return shell, ok +} + +// Kill terminates a background shell by ID. +func (m *BackgroundShellManager) Kill(id string) error { + m.mu.Lock() + shell, ok := m.shells[id] + if !ok { + m.mu.Unlock() + return fmt.Errorf("background shell not found: %s", id) + } + delete(m.shells, id) + m.mu.Unlock() + + shell.cancel() + <-shell.done + return nil +} + +// List returns all background shell IDs. +func (m *BackgroundShellManager) List() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + ids := make([]string, 0, len(m.shells)) + for id := range m.shells { + ids = append(ids, id) + } + return ids +} + +// GetOutput returns the current output of a background shell. +func (bs *BackgroundShell) GetOutput() (stdout string, stderr string, done bool, err error) { + bs.mu.RLock() + defer bs.mu.RUnlock() + + select { + case <-bs.done: + return bs.stdout.String(), bs.stderr.String(), true, bs.exitErr + default: + return bs.stdout.String(), bs.stderr.String(), false, nil + } +} + +// IsDone checks if the background shell has finished execution. +func (bs *BackgroundShell) IsDone() bool { + select { + case <-bs.done: + return true + default: + return false + } +} + +// Wait blocks until the background shell completes. +func (bs *BackgroundShell) Wait() { + <-bs.done +} + +// GetWorkingDir returns the current working directory of the background shell. +func (bs *BackgroundShell) GetWorkingDir() string { + bs.mu.RLock() + defer bs.mu.RUnlock() + return bs.workingDir +} diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go new file mode 100644 index 0000000000000000000000000000000000000000..715164d6a436c613a7f3e520d133f8022c81c3fc --- /dev/null +++ b/internal/shell/background_test.go @@ -0,0 +1,214 @@ +package shell + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestBackgroundShellManager_Start(t *testing.T) { + t.Parallel() + + ctx := context.Background() + workingDir := t.TempDir() + manager := GetBackgroundShellManager() + + bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'") + if err != nil { + t.Fatalf("failed to start background shell: %v", err) + } + + if bgShell.ID == "" { + t.Error("expected shell ID to be non-empty") + } + + // Wait for the command to complete + bgShell.Wait() + + stdout, stderr, done, err := bgShell.GetOutput() + if !done { + t.Error("expected shell to be done") + } + + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + + if !strings.Contains(stdout, "hello world") { + t.Errorf("expected stdout to contain 'hello world', got: %s", stdout) + } + + if stderr != "" { + t.Errorf("expected empty stderr, got: %s", stderr) + } +} + +func TestBackgroundShellManager_Get(t *testing.T) { + t.Parallel() + + ctx := context.Background() + workingDir := t.TempDir() + manager := GetBackgroundShellManager() + + bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'") + if err != nil { + t.Fatalf("failed to start background shell: %v", err) + } + + // Retrieve the shell + retrieved, ok := manager.Get(bgShell.ID) + if !ok { + t.Error("expected to find the background shell") + } + + if retrieved.ID != bgShell.ID { + t.Errorf("expected shell ID %s, got %s", bgShell.ID, retrieved.ID) + } + + // Clean up + manager.Kill(bgShell.ID) +} + +func TestBackgroundShellManager_Kill(t *testing.T) { + t.Parallel() + + ctx := context.Background() + workingDir := t.TempDir() + manager := GetBackgroundShellManager() + + // Start a long-running command + bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10") + if err != nil { + t.Fatalf("failed to start background shell: %v", err) + } + + // Kill it + err = manager.Kill(bgShell.ID) + if err != nil { + t.Errorf("failed to kill background shell: %v", err) + } + + // Verify it's no longer in the manager + _, ok := manager.Get(bgShell.ID) + if ok { + t.Error("expected shell to be removed after kill") + } + + // Verify the shell is done + if !bgShell.IsDone() { + t.Error("expected shell to be done after kill") + } +} + +func TestBackgroundShellManager_KillNonExistent(t *testing.T) { + t.Parallel() + + manager := GetBackgroundShellManager() + + err := manager.Kill("non-existent-id") + if err == nil { + t.Error("expected error when killing non-existent shell") + } +} + +func TestBackgroundShell_IsDone(t *testing.T) { + t.Parallel() + + ctx := context.Background() + workingDir := t.TempDir() + manager := GetBackgroundShellManager() + + bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'") + if err != nil { + t.Fatalf("failed to start background shell: %v", err) + } + + // Wait a bit for the command to complete + time.Sleep(100 * time.Millisecond) + + if !bgShell.IsDone() { + t.Error("expected shell to be done") + } + + // Clean up + manager.Kill(bgShell.ID) +} + +func TestBackgroundShell_WithBlockFuncs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + workingDir := t.TempDir() + manager := GetBackgroundShellManager() + + blockFuncs := []BlockFunc{ + CommandsBlocker([]string{"curl", "wget"}), + } + + bgShell, err := manager.Start(ctx, workingDir, blockFuncs, "curl example.com") + if err != nil { + t.Fatalf("failed to start background shell: %v", err) + } + + // Wait for the command to complete + bgShell.Wait() + + stdout, stderr, done, execErr := bgShell.GetOutput() + if !done { + t.Error("expected shell to be done") + } + + // The command should have been blocked + output := stdout + stderr + if !strings.Contains(output, "not allowed") && execErr == nil { + t.Errorf("expected command to be blocked, got stdout: %s, stderr: %s, err: %v", stdout, stderr, execErr) + } + + // Clean up + manager.Kill(bgShell.ID) +} + +func TestBackgroundShellManager_List(t *testing.T) { + t.Parallel() + + ctx := context.Background() + workingDir := t.TempDir() + manager := GetBackgroundShellManager() + + // Start two shells + bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1") + if err != nil { + t.Fatalf("failed to start first background shell: %v", err) + } + + bgShell2, err := manager.Start(ctx, workingDir, nil, "sleep 1") + if err != nil { + t.Fatalf("failed to start second background shell: %v", err) + } + + ids := manager.List() + + // Check that both shells are in the list + found1 := false + found2 := false + for _, id := range ids { + if id == bgShell1.ID { + found1 = true + } + if id == bgShell2.ID { + found2 = true + } + } + + if !found1 { + t.Errorf("expected to find shell %s in list", bgShell1.ID) + } + if !found2 { + t.Errorf("expected to find shell %s in list", bgShell2.ID) + } + + // Clean up + manager.Kill(bgShell1.ID) + manager.Kill(bgShell2.ID) +} diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 16bca578929dd45e5f14d6ec6323cf1cea8f2fa4..00cd36806e2cd55abaf5bd93b708f080f6f7a27a 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -163,6 +163,8 @@ func (br baseRenderer) renderError(v *toolCallCmp, message string) string { // Register tool renderers func init() { registry.register(tools.BashToolName, func() renderer { return bashRenderer{} }) + registry.register(tools.BashOutputToolName, func() renderer { return bashOutputRenderer{} }) + registry.register(tools.BashKillToolName, func() renderer { return bashKillRenderer{} }) registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} }) registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} }) registry.register(tools.EditToolName, func() renderer { return editRenderer{} }) @@ -213,7 +215,10 @@ func (br bashRenderer) Render(v *toolCallCmp) string { cmd := strings.ReplaceAll(params.Command, "\n", " ") cmd = strings.ReplaceAll(cmd, "\t", " ") - args := newParamBuilder().addMain(cmd).build() + args := newParamBuilder(). + addMain(cmd). + addFlag("background", params.Background). + build() return br.renderWithParams(v, "Bash", args, func() string { var meta tools.BashResponseMetadata @@ -232,6 +237,52 @@ func (br bashRenderer) Render(v *toolCallCmp) string { }) } +// ----------------------------------------------------------------------------- +// Bash Output renderer +// ----------------------------------------------------------------------------- + +// bashOutputRenderer handles bash output retrieval display +type bashOutputRenderer struct { + baseRenderer +} + +// Render displays the shell ID and output from a background shell +func (bor bashOutputRenderer) Render(v *toolCallCmp) string { + var params tools.BashOutputParams + if err := bor.unmarshalParams(v.call.Input, ¶ms); err != nil { + return bor.renderError(v, "Invalid bash_output parameters") + } + + args := newParamBuilder().addMain(params.ShellID).build() + + return bor.renderWithParams(v, "Bash Output", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// Bash Kill renderer +// ----------------------------------------------------------------------------- + +// bashKillRenderer handles bash process termination display +type bashKillRenderer struct { + baseRenderer +} + +// Render displays the shell ID being terminated +func (bkr bashKillRenderer) Render(v *toolCallCmp) string { + var params tools.BashKillParams + if err := bkr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return bkr.renderError(v, "Invalid bash_kill parameters") + } + + args := newParamBuilder().addMain(params.ShellID).build() + + return bkr.renderWithParams(v, "Bash Kill", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + // ----------------------------------------------------------------------------- // View renderer // ----------------------------------------------------------------------------- @@ -1002,6 +1053,10 @@ func prettifyToolName(name string) string { return "Agent" case tools.BashToolName: return "Bash" + case tools.BashOutputToolName: + return "Bash Output" + case tools.BashKillToolName: + return "Bash Kill" case tools.DownloadToolName: return "Download" case tools.EditToolName: