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: