wip: background jobs

kujtimiihoxha created

Change summary

go.mod                                            |   1 
go.sum                                            |   2 
internal/agent/coordinator.go                     |   2 
internal/agent/tools/bash.go                      |  36 +
internal/agent/tools/bash.tpl                     |  11 
internal/agent/tools/bash_background_test.go      | 357 +++++++++++++++++
internal/agent/tools/bash_kill.go                 |  63 +++
internal/agent/tools/bash_output.go               |  98 ++++
internal/config/config.go                         |   2 
internal/config/load_test.go                      |   4 
internal/shell/background.go                      | 157 +++++++
internal/shell/background_test.go                 | 214 ++++++++++
internal/tui/components/chat/messages/renderer.go |  57 ++
13 files changed, 997 insertions(+), 7 deletions(-)

Detailed changes

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

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=

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()),

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)

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)
 </usage_notes>
 
+<background_execution>
+- 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
+</background_execution>
+
 <git_commits>
 When user asks to create git commit:
 

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")
+}

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.
+
+<usage>
+- Provide the shell ID returned from a background bash execution
+- Cancels the running process and cleans up resources
+</usage>
+
+<features>
+- Stop long-running background processes
+- Clean up completed background shells
+- Immediately terminates the process
+</features>
+
+<tips>
+- 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
+</tips>
+`
+
+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
+		})
+}

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.
+
+<usage>
+- Provide the shell ID returned from a background bash execution
+- Returns the current stdout and stderr output
+- Indicates whether the shell has completed execution
+</usage>
+
+<features>
+- View output from running background processes
+- Check if background process has completed
+- Get cumulative output from process start
+</features>
+
+<tips>
+- 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
+</tips>
+`
+
+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
+		})
+}

internal/config/config.go 🔗

@@ -469,6 +469,8 @@ func allToolNames() []string {
 	return []string{
 		"agent",
 		"bash",
+		"bash_output",
+		"bash_kill",
 		"download",
 		"edit",
 		"multiedit",

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)

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
+}

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)
+}

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, &params); 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, &params); 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: