浏览代码

feat: background jobs & remove persistent shell (#1328)

Co-authored-by: Christian Rocha <[email protected]>
Co-authored-by: Raphael Amorim <[email protected]>
Co-authored-by: Andrey Nering <[email protected]>
Kujtim Hoxha 3 月之前
父节点
当前提交
4401d5b37f
共有 74 个文件被更改,包括 2047 次插入710 次删除
  1. 0 2
      internal/agent/agent_test.go
  2. 2 0
      internal/agent/coordinator.go
  3. 1 0
      internal/agent/templates/coder.md.tpl
  4. 11 14
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml
  5. 10 16
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml
  6. 12 12
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml
  7. 10 16
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml
  8. 11 14
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml
  9. 14 14
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml
  10. 11 14
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml
  11. 12 18
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml
  12. 10 13
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml
  13. 9 9
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml
  14. 12 12
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml
  15. 11 11
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml
  16. 12 12
      internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml
  17. 19 11
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml
  18. 13 11
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml
  19. 16 12
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml
  20. 14 14
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml
  21. 15 15
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml
  22. 10 20
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml
  23. 21 17
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml
  24. 16 14
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml
  25. 12 8
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml
  26. 6 6
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml
  27. 17 17
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml
  28. 15 15
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml
  29. 23 11
      internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml
  30. 1 2
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml
  31. 9 15
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml
  32. 1 1
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml
  33. 10 10
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml
  34. 18 8
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml
  35. 10 8
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml
  36. 12 8
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml
  37. 10 10
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml
  38. 9 9
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml
  39. 6 6
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml
  40. 11 19
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml
  41. 17 9
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml
  42. 8 8
      internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml
  43. 12 10
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml
  44. 9 13
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml
  45. 1 1
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml
  46. 11 9
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml
  47. 9 13
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml
  48. 1 1
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml
  49. 10 10
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml
  50. 9 9
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml
  51. 7 9
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml
  52. 5 5
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml
  53. 1 1
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml
  54. 14 10
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml
  55. 11 9
      internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml
  56. 173 66
      internal/agent/tools/bash.go
  57. 25 5
      internal/agent/tools/bash.tpl
  58. 59 0
      internal/agent/tools/job_kill.go
  59. 18 0
      internal/agent/tools/job_kill.md
  60. 85 0
      internal/agent/tools/job_output.go
  61. 19 0
      internal/agent/tools/job_output.md
  62. 371 0
      internal/agent/tools/job_test.go
  63. 4 0
      internal/app/app.go
  64. 2 0
      internal/config/config.go
  65. 3 2
      internal/config/load_test.go
  66. 201 0
      internal/shell/background.go
  67. 276 0
      internal/shell/background_test.go
  68. 1 6
      internal/shell/doc.go
  69. 0 43
      internal/shell/persistent.go
  70. 50 20
      internal/shell/shell.go
  71. 157 1
      internal/tui/components/chat/messages/renderer.go
  72. 34 6
      internal/tui/components/dialogs/permissions/permissions.go
  73. 1 0
      internal/tui/styles/charmtone.go
  74. 1 0
      internal/tui/styles/theme.go

+ 0 - 2
internal/agent/agent_test.go

@@ -10,7 +10,6 @@ import (
 	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/shell"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
@@ -40,7 +39,6 @@ func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
 
 	createSimpleGoProject(t, env.workingDir)
 	agent, err := coderAgent(r, env, large, small)
-	shell.Reset(env.workingDir)
 	require.NoError(t, err)
 	return agent, env
 }

+ 2 - 0
internal/agent/coordinator.go

@@ -329,6 +329,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.NewJobOutputTool(),
+		tools.NewJobKillTool(),
 		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()),

+ 1 - 0
internal/agent/templates/coder.md.tpl

@@ -267,6 +267,7 @@ After significant changes:
 - Run tools in parallel when safe (no dependencies)
 - When making multiple independent bash calls, send them in a single message with multiple tool calls for parallel execution
 - Summarize tool output for user (they don't see it)
+- Never use `curl` through the bash tool it is not allowed use the fetch tool instead.
 
 <bash_commands>
 When running non-trivial bash commands (especially those that modify the system):

文件差异内容过多而无法显示
+ 11 - 14
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml


文件差异内容过多而无法显示
+ 10 - 16
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml


文件差异内容过多而无法显示
+ 12 - 12
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml


文件差异内容过多而无法显示
+ 10 - 16
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml


文件差异内容过多而无法显示
+ 11 - 14
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml


文件差异内容过多而无法显示
+ 14 - 14
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml


文件差异内容过多而无法显示
+ 11 - 14
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml


文件差异内容过多而无法显示
+ 12 - 18
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml


文件差异内容过多而无法显示
+ 10 - 13
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml


文件差异内容过多而无法显示
+ 9 - 9
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml


文件差异内容过多而无法显示
+ 12 - 12
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml


文件差异内容过多而无法显示
+ 11 - 11
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml


文件差异内容过多而无法显示
+ 12 - 12
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml


文件差异内容过多而无法显示
+ 19 - 11
internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml


文件差异内容过多而无法显示
+ 13 - 11
internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml


文件差异内容过多而无法显示
+ 16 - 12
internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml


文件差异内容过多而无法显示
+ 14 - 14
internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml


文件差异内容过多而无法显示
+ 15 - 15
internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml


文件差异内容过多而无法显示
+ 10 - 20
internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml


文件差异内容过多而无法显示
+ 21 - 17
internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml


文件差异内容过多而无法显示
+ 16 - 14
internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml


文件差异内容过多而无法显示
+ 12 - 8
internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml


文件差异内容过多而无法显示
+ 6 - 6
internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml


文件差异内容过多而无法显示
+ 17 - 17
internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml


文件差异内容过多而无法显示
+ 15 - 15
internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml


文件差异内容过多而无法显示
+ 23 - 11
internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml


文件差异内容过多而无法显示
+ 1 - 2
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml


文件差异内容过多而无法显示
+ 9 - 15
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml


文件差异内容过多而无法显示
+ 1 - 1
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml


文件差异内容过多而无法显示
+ 10 - 10
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml


文件差异内容过多而无法显示
+ 18 - 8
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml


文件差异内容过多而无法显示
+ 10 - 8
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml


文件差异内容过多而无法显示
+ 12 - 8
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml


文件差异内容过多而无法显示
+ 10 - 10
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml


文件差异内容过多而无法显示
+ 9 - 9
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml


文件差异内容过多而无法显示
+ 6 - 6
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml


文件差异内容过多而无法显示
+ 11 - 19
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml


文件差异内容过多而无法显示
+ 17 - 9
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml


文件差异内容过多而无法显示
+ 8 - 8
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml


文件差异内容过多而无法显示
+ 12 - 10
internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml


文件差异内容过多而无法显示
+ 9 - 13
internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml


文件差异内容过多而无法显示
+ 1 - 1
internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml


文件差异内容过多而无法显示
+ 11 - 9
internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml


文件差异内容过多而无法显示
+ 9 - 13
internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml


文件差异内容过多而无法显示
+ 1 - 1
internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml


文件差异内容过多而无法显示
+ 10 - 10
internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml


文件差异内容过多而无法显示
+ 9 - 9
internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml


文件差异内容过多而无法显示
+ 7 - 9
internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml


文件差异内容过多而无法显示
+ 5 - 5
internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml


文件差异内容过多而无法显示
+ 1 - 1
internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml


文件差异内容过多而无法显示
+ 14 - 10
internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml


文件差异内容过多而无法显示
+ 11 - 9
internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml


+ 173 - 66
internal/agent/tools/bash.go

@@ -2,6 +2,7 @@ package tools
 
 import (
 	"bytes"
+	"cmp"
 	"context"
 	_ "embed"
 	"fmt"
@@ -19,15 +20,17 @@ import (
 )
 
 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)"`
+	Description     string `json:"description" description:"A brief description of what the command does, try to keep it under 30 characters or so"`
+	Command         string `json:"command" description:"The command to execute"`
+	WorkingDir      string `json:"working_dir,omitempty" description:"The working directory to execute the command in (defaults to current directory)"`
+	RunInBackground bool   `json:"run_in_background,omitempty" description:"Set to true (boolean) to run this command in the background. Use job_output to read the output later."`
 }
 
 type BashPermissionsParams struct {
-	Command     string `json:"command"`
-	Description string `json:"description"`
-	Timeout     int    `json:"timeout"`
+	Description     string `json:"description"`
+	Command         string `json:"command"`
+	WorkingDir      string `json:"working_dir"`
+	RunInBackground bool   `json:"run_in_background"`
 }
 
 type BashResponseMetadata struct {
@@ -36,15 +39,16 @@ 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 (
 	BashToolName = "bash"
 
-	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
-	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
-	MaxOutputLength = 30000
-	BashNoOutput    = "no output"
+	AutoBackgroundThreshold = 1 * time.Minute // Commands taking longer automatically become background jobs
+	MaxOutputLength         = 30000
+	BashNoOutput            = "no output"
 )
 
 //go:embed bash.tpl
@@ -181,23 +185,17 @@ func blockFuncs() []shell.BlockFunc {
 }
 
 func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) fantasy.AgentTool {
-	// Set up command blocking on the persistent shell
-	persistentShell := shell.GetPersistentShell(workingDir)
-	persistentShell.SetBlockFuncs(blockFuncs())
 	return fantasy.NewAgentTool(
 		BashToolName,
 		string(bashDescription(attribution)),
 		func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if params.Timeout > MaxTimeout {
-				params.Timeout = MaxTimeout
-			} else if params.Timeout <= 0 {
-				params.Timeout = DefaultTimeout
-			}
-
 			if params.Command == "" {
 				return fantasy.NewTextErrorResponse("missing command"), nil
 			}
 
+			// Determine working directory
+			execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
+
 			isSafeReadOnly := false
 			cmdLower := strings.ToLower(params.Command)
 
@@ -215,88 +213,197 @@ 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)
 				p := permissions.Request(
 					permission.CreatePermissionRequest{
 						SessionID:   sessionID,
-						Path:        shell.GetWorkingDir(),
+						Path:        execWorkingDir,
 						ToolCallID:  call.ID,
 						ToolName:    BashToolName,
 						Action:      "execute",
 						Description: fmt.Sprintf("Execute command: %s", params.Command),
-						Params: BashPermissionsParams{
-							Command:     params.Command,
-							Description: params.Description,
-						},
+						Params:      BashPermissionsParams(params),
 					},
 				)
 				if !p {
 					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 				}
 			}
-			startTime := time.Now()
-			if params.Timeout > 0 {
-				var cancel context.CancelFunc
-				ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
-				defer cancel()
-			}
 
-			persistentShell := shell.GetPersistentShell(workingDir)
-			stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
+			// If explicitly requested as background, start immediately with detached context
+			if params.RunInBackground {
+				startTime := time.Now()
+				bgManager := shell.GetBackgroundShellManager()
+				bgManager.Cleanup()
+				// Use background context so it continues after tool returns
+				bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
+				if err != nil {
+					return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
+				}
 
-			// Get the current working directory after command execution
-			currentWorkingDir := persistentShell.GetWorkingDir()
-			interrupted := shell.IsInterrupt(err)
-			exitCode := shell.ExitCode(err)
-			if exitCode == 0 && !interrupted && err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error executing command: %w", err)
-			}
+				// Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
+				time.Sleep(1 * time.Second)
+				stdout, stderr, done, execErr := bgShell.GetOutput()
 
-			stdout = truncateOutput(stdout)
-			stderr = truncateOutput(stderr)
+				if done {
+					// Command failed or completed very quickly
+					bgManager.Remove(bgShell.ID)
 
-			errorMessage := stderr
-			if errorMessage == "" && err != nil {
-				errorMessage = err.Error()
-			}
+					interrupted := shell.IsInterrupt(execErr)
+					exitCode := shell.ExitCode(execErr)
+					if exitCode == 0 && !interrupted && execErr != nil {
+						return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
+					}
+
+					stdout = formatOutput(stdout, stderr, execErr)
 
-			if interrupted {
-				if errorMessage != "" {
-					errorMessage += "\n"
+					metadata := BashResponseMetadata{
+						StartTime:        startTime.UnixMilli(),
+						EndTime:          time.Now().UnixMilli(),
+						Output:           stdout,
+						Description:      params.Description,
+						Background:       params.RunInBackground,
+						WorkingDirectory: bgShell.WorkingDir,
+					}
+					if stdout == "" {
+						return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
+					}
+					stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
+					return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
 				}
-				errorMessage += "Command was aborted before completion"
-			} else if exitCode != 0 {
-				if errorMessage != "" {
-					errorMessage += "\n"
+
+				// Still running after fast-failure check - return as background job
+				metadata := BashResponseMetadata{
+					StartTime:        startTime.UnixMilli(),
+					EndTime:          time.Now().UnixMilli(),
+					Description:      params.Description,
+					WorkingDirectory: bgShell.WorkingDir,
+					Background:       true,
+					ShellID:          bgShell.ID,
 				}
-				errorMessage += fmt.Sprintf("Exit code %d", exitCode)
+				response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
+				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
 			}
 
-			hasBothOutputs := stdout != "" && stderr != ""
+			// Start synchronous execution with auto-background support
+			startTime := time.Now()
+
+			// Start with detached context so it can survive if moved to background
+			bgManager := shell.GetBackgroundShellManager()
+			bgManager.Cleanup()
+			bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
+			}
 
-			if hasBothOutputs {
-				stdout += "\n"
+			// Wait for either completion, auto-background threshold, or context cancellation
+			ticker := time.NewTicker(100 * time.Millisecond)
+			defer ticker.Stop()
+			timeout := time.After(AutoBackgroundThreshold)
+
+			var stdout, stderr string
+			var done bool
+			var execErr error
+
+		waitLoop:
+			for {
+				select {
+				case <-ticker.C:
+					stdout, stderr, done, execErr = bgShell.GetOutput()
+					if done {
+						break waitLoop
+					}
+				case <-timeout:
+					stdout, stderr, done, execErr = bgShell.GetOutput()
+					break waitLoop
+				case <-ctx.Done():
+					// Incoming context was cancelled before we moved to background
+					// Kill the shell and return error
+					bgManager.Kill(bgShell.ID)
+					return fantasy.ToolResponse{}, ctx.Err()
+				}
 			}
 
-			if errorMessage != "" {
-				stdout += "\n" + errorMessage
+			if done {
+				// Command completed within threshold - return synchronously
+				// Remove from background manager since we're returning directly
+				// Don't call Kill() as it cancels the context and corrupts the exit code
+				bgManager.Remove(bgShell.ID)
+
+				interrupted := shell.IsInterrupt(execErr)
+				exitCode := shell.ExitCode(execErr)
+				if exitCode == 0 && !interrupted && execErr != nil {
+					return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
+				}
+
+				stdout = formatOutput(stdout, stderr, execErr)
+
+				metadata := BashResponseMetadata{
+					StartTime:        startTime.UnixMilli(),
+					EndTime:          time.Now().UnixMilli(),
+					Output:           stdout,
+					Description:      params.Description,
+					Background:       params.RunInBackground,
+					WorkingDirectory: bgShell.WorkingDir,
+				}
+				if stdout == "" {
+					return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
+				}
+				stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
+				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
 			}
 
+			// Still running - keep as background job
 			metadata := BashResponseMetadata{
 				StartTime:        startTime.UnixMilli(),
 				EndTime:          time.Now().UnixMilli(),
-				Output:           stdout,
 				Description:      params.Description,
-				WorkingDirectory: currentWorkingDir,
-			}
-			if stdout == "" {
-				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
+				WorkingDirectory: bgShell.WorkingDir,
+				Background:       true,
+				ShellID:          bgShell.ID,
 			}
-			stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(currentWorkingDir))
-			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
+			response := fmt.Sprintf("Command is taking longer than expected and has been moved to background.\n\nBackground shell ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
 		})
 }
 
+// formatOutput formats the output of a completed command with error handling
+func formatOutput(stdout, stderr string, execErr error) string {
+	interrupted := shell.IsInterrupt(execErr)
+	exitCode := shell.ExitCode(execErr)
+
+	stdout = truncateOutput(stdout)
+	stderr = truncateOutput(stderr)
+
+	errorMessage := stderr
+	if errorMessage == "" && execErr != nil {
+		errorMessage = execErr.Error()
+	}
+
+	if interrupted {
+		if errorMessage != "" {
+			errorMessage += "\n"
+		}
+		errorMessage += "Command was aborted before completion"
+	} else if exitCode != 0 {
+		if errorMessage != "" {
+			errorMessage += "\n"
+		}
+		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
+	}
+
+	hasBothOutputs := stdout != "" && stderr != ""
+
+	if hasBothOutputs {
+		stdout += "\n"
+	}
+
+	if errorMessage != "" {
+		stdout += "\n" + errorMessage
+	}
+
+	return stdout
+}
+
 func truncateOutput(content string) string {
 	if len(content) <= MaxOutputLength {
 		return content

+ 25 - 5
internal/agent/tools/bash.tpl

@@ -1,4 +1,4 @@
-Executes bash commands in persistent shell session with timeout and security measures.
+Executes bash commands with automatic background conversion for long-running tasks.
 
 <cross_platform>
 Uses mvdan/sh interpreter (Bash-compatible on all platforms including Windows).
@@ -10,18 +10,38 @@ Common shell builtins and core utils available on Windows.
 1. Directory Verification: If creating directories/files, use LS tool to verify parent exists
 2. Security Check: Banned commands ({{ .BannedCommands }}) return error - explain to user. Safe read-only commands execute without prompts
 3. Command Execution: Execute with proper quoting, capture output
-4. Output Processing: Truncate if exceeds {{ .MaxOutputLength }} characters
-5. Return Result: Include errors, metadata with <cwd></cwd> tags
+4. Auto-Background: Commands exceeding 1 minute automatically move to background and return shell ID
+5. Output Processing: Truncate if exceeds {{ .MaxOutputLength }} characters
+6. Return Result: Include errors, metadata with <cwd></cwd> tags
 </execution_steps>
 
 <usage_notes>
-- Command required, timeout optional (max 600000ms/10min, default 30min if unspecified)
+- Command required, working_dir optional (defaults to current directory)
 - 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.)
+- Each command runs in independent shell (no state persistence between calls)
 - Prefer absolute paths over 'cd' (use 'cd' only if user explicitly requests)
 </usage_notes>
 
+<background_execution>
+- Set run_in_background=true to run commands in a separate background shell
+- Returns a shell ID for managing the background process
+- Use job_output tool to view current output from background shell
+- Use job_kill tool to terminate a background shell
+- IMPORTANT: NEVER use `&` at the end of commands to run in background - use run_in_background parameter instead
+- Commands that should run in background:
+  * Long-running servers (e.g., `npm start`, `python -m http.server`, `node server.js`)
+  * Watch/monitoring tasks (e.g., `npm run watch`, `tail -f logfile`)
+  * Continuous processes that don't exit on their own
+  * Any command expected to run indefinitely
+- Commands that should NOT run in background:
+  * Build commands (e.g., `npm run build`, `go build`)
+  * Test suites (e.g., `npm test`, `pytest`)
+  * Git operations
+  * File operations
+  * Short-lived scripts
+</background_execution>
+
 <git_commits>
 When user asks to create git commit:
 

+ 59 - 0
internal/agent/tools/job_kill.go

@@ -0,0 +1,59 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+const (
+	JobKillToolName = "job_kill"
+)
+
+//go:embed job_kill.md
+var jobKillDescription []byte
+
+type JobKillParams struct {
+	ShellID string `json:"shell_id" description:"The ID of the background shell to terminate"`
+}
+
+type JobKillResponseMetadata struct {
+	ShellID     string `json:"shell_id"`
+	Command     string `json:"command"`
+	Description string `json:"description"`
+}
+
+func NewJobKillTool() fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		JobKillToolName,
+		string(jobKillDescription),
+		func(ctx context.Context, params JobKillParams, 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
+			}
+
+			metadata := JobKillResponseMetadata{
+				ShellID:     params.ShellID,
+				Command:     bgShell.Command,
+				Description: bgShell.Description,
+			}
+
+			err := bgManager.Kill(params.ShellID)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+
+			result := fmt.Sprintf("Background shell %s terminated successfully", params.ShellID)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), metadata), nil
+		})
+}

+ 18 - 0
internal/agent/tools/job_kill.md

@@ -0,0 +1,18 @@
+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>

+ 85 - 0
internal/agent/tools/job_output.go

@@ -0,0 +1,85 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+const (
+	JobOutputToolName = "job_output"
+)
+
+//go:embed job_output.md
+var jobOutputDescription []byte
+
+type JobOutputParams struct {
+	ShellID string `json:"shell_id" description:"The ID of the background shell to retrieve output from"`
+}
+
+type JobOutputResponseMetadata struct {
+	ShellID          string `json:"shell_id"`
+	Command          string `json:"command"`
+	Description      string `json:"description"`
+	Done             bool   `json:"done"`
+	WorkingDirectory string `json:"working_directory"`
+}
+
+func NewJobOutputTool() fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		JobOutputToolName,
+		string(jobOutputDescription),
+		func(ctx context.Context, params JobOutputParams, 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 := JobOutputResponseMetadata{
+				ShellID:          params.ShellID,
+				Command:          bgShell.Command,
+				Description:      bgShell.Description,
+				Done:             done,
+				WorkingDirectory: bgShell.WorkingDir,
+			}
+
+			if output == "" {
+				output = BashNoOutput
+			}
+
+			result := fmt.Sprintf("Status: %s\n\n%s", status, output)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), metadata), nil
+		})
+}

+ 19 - 0
internal/agent/tools/job_output.md

@@ -0,0 +1,19 @@
+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>

+ 371 - 0
internal/agent/tools/job_test.go

@@ -0,0 +1,371 @@
+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_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, "while true; do echo \"Hello from background job - $(date +%T)\"; sleep 1; done", "")
+	require.NoError(t, err)
+	defer bgManager.Kill(bgShell.ID)
+	// wait for 2 seconds
+	time.Sleep(2 * time.Second)
+	stdout, _, _, err := bgShell.GetOutput()
+	require.NoError(t, err)
+	require.Len(t, strings.Split(stdout, "\n"), 3)
+}
+
+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.WorkingDir
+					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_AutoBackground(t *testing.T) {
+	t.Parallel()
+
+	workingDir := t.TempDir()
+	ctx := context.Background()
+
+	// Test that a quick command completes synchronously
+	t.Run("quick command completes synchronously", func(t *testing.T) {
+		t.Parallel()
+		bgManager := shell.GetBackgroundShellManager()
+		bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'quick'", "")
+		require.NoError(t, err)
+
+		// Wait threshold time
+		time.Sleep(5 * time.Second)
+
+		// Should be done by now
+		stdout, stderr, done, err := bgShell.GetOutput()
+		require.NoError(t, err)
+		require.True(t, done, "Quick command should be done")
+		require.Contains(t, stdout, "quick")
+		require.Empty(t, stderr)
+
+		// Clean up
+		bgManager.Kill(bgShell.ID)
+	})
+
+	// Test that a long command stays in background
+	t.Run("long command stays in background", func(t *testing.T) {
+		t.Parallel()
+		bgManager := shell.GetBackgroundShellManager()
+		bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 20 && echo '20 seconds completed'", "")
+		require.NoError(t, err)
+		defer bgManager.Kill(bgShell.ID)
+
+		// Wait threshold time
+		time.Sleep(5 * time.Second)
+
+		// Should still be running
+		stdout, stderr, done, err := bgShell.GetOutput()
+		require.NoError(t, err)
+		require.False(t, done, "Long command should still be running")
+		require.Empty(t, stdout, "No output yet from sleep command")
+		require.Empty(t, stderr)
+
+		// Verify we can get the shell from manager
+		retrieved, ok := bgManager.Get(bgShell.ID)
+		require.True(t, ok, "Should be able to retrieve background shell")
+		require.Equal(t, bgShell.ID, retrieved.ID)
+	})
+
+	// Test that we can check output of long-running command later
+	t.Run("can check output after completion", func(t *testing.T) {
+		t.Parallel()
+		bgManager := shell.GetBackgroundShellManager()
+		bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 3 && echo 'completed'", "")
+		require.NoError(t, err)
+		defer bgManager.Kill(bgShell.ID)
+
+		// Initially should be running
+		_, _, done, _ := bgShell.GetOutput()
+		require.False(t, done, "Should be running initially")
+
+		// Wait for completion
+		time.Sleep(4 * time.Second)
+
+		// Now should be done
+		stdout, stderr, done, err := bgShell.GetOutput()
+		require.NoError(t, err)
+		require.True(t, done, "Should be done after waiting")
+		require.Contains(t, stdout, "completed")
+		require.Empty(t, stderr)
+	})
+}

+ 4 - 0
internal/app/app.go

@@ -29,6 +29,7 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/shell"
 	"github.com/charmbracelet/crush/internal/term"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -368,6 +369,9 @@ func (app *App) Shutdown() {
 		app.AgentCoordinator.CancelAll()
 	}
 
+	// Kill all background shells.
+	shell.GetBackgroundShellManager().KillAll()
+
 	// Shutdown all LSP clients.
 	for name, client := range app.LSPClients.Seq2() {
 		shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)

+ 2 - 0
internal/config/config.go

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

+ 3 - 2
internal/config/load_test.go

@@ -485,7 +485,8 @@ 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", "job_output", "job_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 +509,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", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)

+ 201 - 0
internal/shell/background.go

@@ -0,0 +1,201 @@
+package shell
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/csync"
+)
+
+const (
+	// MaxBackgroundJobs is the maximum number of concurrent background jobs allowed
+	MaxBackgroundJobs = 50
+	// CompletedJobRetentionMinutes is how long to keep completed jobs before auto-cleanup (8 hours)
+	CompletedJobRetentionMinutes = 8 * 60
+)
+
+// BackgroundShell represents a shell running in the background.
+type BackgroundShell struct {
+	ID          string
+	Command     string
+	Description string
+	Shell       *Shell
+	WorkingDir  string
+	ctx         context.Context
+	cancel      context.CancelFunc
+	stdout      *bytes.Buffer
+	stderr      *bytes.Buffer
+	done        chan struct{}
+	exitErr     error
+	completedAt int64 // Unix timestamp when job completed (0 if still running)
+}
+
+// BackgroundShellManager manages background shell instances.
+type BackgroundShellManager struct {
+	shells *csync.Map[string, *BackgroundShell]
+}
+
+var (
+	backgroundManager     *BackgroundShellManager
+	backgroundManagerOnce sync.Once
+	idCounter             atomic.Uint64
+)
+
+// GetBackgroundShellManager returns the singleton background shell manager.
+func GetBackgroundShellManager() *BackgroundShellManager {
+	backgroundManagerOnce.Do(func() {
+		backgroundManager = &BackgroundShellManager{
+			shells: csync.NewMap[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, description string) (*BackgroundShell, error) {
+	// Check job limit
+	if m.shells.Len() >= MaxBackgroundJobs {
+		return nil, fmt.Errorf("maximum number of background jobs (%d) reached. Please terminate or wait for some jobs to complete", MaxBackgroundJobs)
+	}
+
+	id := fmt.Sprintf("%03X", idCounter.Add(1))
+
+	shell := NewShell(&Options{
+		WorkingDir: workingDir,
+		BlockFuncs: blockFuncs,
+	})
+
+	shellCtx, cancel := context.WithCancel(ctx)
+
+	bgShell := &BackgroundShell{
+		ID:          id,
+		Command:     command,
+		Description: description,
+		WorkingDir:  workingDir,
+		Shell:       shell,
+		ctx:         shellCtx,
+		cancel:      cancel,
+		stdout:      &bytes.Buffer{},
+		stderr:      &bytes.Buffer{},
+		done:        make(chan struct{}),
+	}
+
+	m.shells.Set(id, bgShell)
+
+	go func() {
+		defer close(bgShell.done)
+
+		err := shell.ExecStream(shellCtx, command, bgShell.stdout, bgShell.stderr)
+
+		bgShell.exitErr = err
+		atomic.StoreInt64(&bgShell.completedAt, time.Now().Unix())
+	}()
+
+	return bgShell, nil
+}
+
+// Get retrieves a background shell by ID.
+func (m *BackgroundShellManager) Get(id string) (*BackgroundShell, bool) {
+	return m.shells.Get(id)
+}
+
+// Remove removes a background shell from the manager without terminating it.
+// This is useful when a shell has already completed and you just want to clean up tracking.
+func (m *BackgroundShellManager) Remove(id string) error {
+	_, ok := m.shells.Take(id)
+	if !ok {
+		return fmt.Errorf("background shell not found: %s", id)
+	}
+	return nil
+}
+
+// Kill terminates a background shell by ID.
+func (m *BackgroundShellManager) Kill(id string) error {
+	shell, ok := m.shells.Take(id)
+	if !ok {
+		return fmt.Errorf("background shell not found: %s", id)
+	}
+
+	shell.cancel()
+	<-shell.done
+	return nil
+}
+
+// BackgroundShellInfo contains information about a background shell.
+type BackgroundShellInfo struct {
+	ID          string
+	Command     string
+	Description string
+}
+
+// List returns all background shell IDs.
+func (m *BackgroundShellManager) List() []string {
+	ids := make([]string, 0, m.shells.Len())
+	for id := range m.shells.Seq2() {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+// Cleanup removes completed jobs that have been finished for more than the retention period
+func (m *BackgroundShellManager) Cleanup() int {
+	now := time.Now().Unix()
+	retentionSeconds := int64(CompletedJobRetentionMinutes * 60)
+
+	var toRemove []string
+	for shell := range m.shells.Seq() {
+		completedAt := atomic.LoadInt64(&shell.completedAt)
+		if completedAt > 0 && now-completedAt > retentionSeconds {
+			toRemove = append(toRemove, shell.ID)
+		}
+	}
+
+	for _, id := range toRemove {
+		m.Remove(id)
+	}
+
+	return len(toRemove)
+}
+
+// KillAll terminates all background shells.
+func (m *BackgroundShellManager) KillAll() {
+	shells := make([]*BackgroundShell, 0, m.shells.Len())
+	for shell := range m.shells.Seq() {
+		shells = append(shells, shell)
+	}
+	m.shells.Reset(map[string]*BackgroundShell{})
+
+	for _, shell := range shells {
+		shell.cancel()
+		<-shell.done
+	}
+}
+
+// GetOutput returns the current output of a background shell.
+func (bs *BackgroundShell) GetOutput() (stdout string, stderr string, done bool, err error) {
+	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
+}

+ 276 - 0
internal/shell/background_test.go

@@ -0,0 +1,276 @@
+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)
+}
+
+func TestBackgroundShellManager_KillAll(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	workingDir := t.TempDir()
+	manager := GetBackgroundShellManager()
+
+	// Start multiple long-running shells
+	shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
+	if err != nil {
+		t.Fatalf("failed to start shell 1: %v", err)
+	}
+
+	shell2, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
+	if err != nil {
+		t.Fatalf("failed to start shell 2: %v", err)
+	}
+
+	shell3, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
+	if err != nil {
+		t.Fatalf("failed to start shell 3: %v", err)
+	}
+
+	// Verify shells are running
+	if shell1.IsDone() || shell2.IsDone() || shell3.IsDone() {
+		t.Error("shells should not be done yet")
+	}
+
+	// Kill all shells
+	manager.KillAll()
+
+	// Verify all shells are done
+	if !shell1.IsDone() {
+		t.Error("shell1 should be done after KillAll")
+	}
+	if !shell2.IsDone() {
+		t.Error("shell2 should be done after KillAll")
+	}
+	if !shell3.IsDone() {
+		t.Error("shell3 should be done after KillAll")
+	}
+
+	// Verify they're removed from the manager
+	if _, ok := manager.Get(shell1.ID); ok {
+		t.Error("shell1 should be removed from manager")
+	}
+	if _, ok := manager.Get(shell2.ID); ok {
+		t.Error("shell2 should be removed from manager")
+	}
+	if _, ok := manager.Get(shell3.ID); ok {
+		t.Error("shell3 should be removed from manager")
+	}
+
+	// Verify list is empty (or doesn't contain our shells)
+	ids := manager.List()
+	for _, id := range ids {
+		if id == shell1.ID || id == shell2.ID || id == shell3.ID {
+			t.Errorf("shell %s should not be in list after KillAll", id)
+		}
+	}
+}

+ 1 - 6
internal/shell/doc.go

@@ -16,12 +16,7 @@ package shell
 //	shell.Exec(ctx, "export FOO=bar")
 //	shell.Exec(ctx, "echo $FOO")  // Will print "bar"
 //
-// 3. For the singleton persistent shell (used by tools):
-//
-//	shell := shell.GetPersistentShell("/path/to/cwd")
-//	stdout, stderr, err := shell.Exec(ctx, "ls -la")
-//
-// 4. Managing environment and working directory:
+// 3. Managing environment and working directory:
 //
 //	shell := shell.NewShell(nil)
 //	shell.SetEnv("MY_VAR", "value")

+ 0 - 43
internal/shell/persistent.go

@@ -1,43 +0,0 @@
-package shell
-
-import (
-	"log/slog"
-	"sync"
-)
-
-// PersistentShell is a singleton shell instance that maintains state across the application
-type PersistentShell struct {
-	*Shell
-}
-
-var (
-	once          sync.Once
-	shellInstance *PersistentShell
-)
-
-// GetPersistentShell returns the singleton persistent shell instance
-// This maintains backward compatibility with the existing API
-func GetPersistentShell(cwd string) *PersistentShell {
-	once.Do(func() {
-		shellInstance = &PersistentShell{
-			Shell: NewShell(&Options{
-				WorkingDir: cwd,
-				Logger:     &loggingAdapter{},
-			}),
-		}
-	})
-	return shellInstance
-}
-
-// INFO: only used for tests
-func Reset(cwd string) {
-	once = sync.Once{}
-	_ = GetPersistentShell(cwd)
-}
-
-// slog.dapter adapts the internal slog.package to the Logger interface
-type loggingAdapter struct{}
-
-func (l *loggingAdapter) InfoPersist(msg string, keysAndValues ...any) {
-	slog.Info(msg, keysAndValues...)
-}

+ 50 - 20
internal/shell/shell.go

@@ -1,13 +1,12 @@
 // Package shell provides cross-platform shell execution capabilities.
 //
-// This package offers two main types:
-// - Shell: A general-purpose shell executor for one-off or managed commands
-// - PersistentShell: A singleton shell that maintains state across the application
+// This package provides Shell instances for executing commands with their own
+// working directory and environment. Each shell execution is independent.
 //
 // WINDOWS COMPATIBILITY:
-// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3),
-// even on Windows. Some caution has to be taken: commands should have forward
-// slashes (/) as path separators to work, even on Windows.
+// This implementation provides POSIX shell emulation (mvdan.cc/sh/v3) even on
+// Windows. Commands should use forward slashes (/) as path separators to work
+// correctly on all platforms.
 package shell
 
 import (
@@ -15,6 +14,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 	"slices"
 	"strings"
@@ -103,6 +103,14 @@ func (s *Shell) Exec(ctx context.Context, command string) (string, string, error
 	return s.exec(ctx, command)
 }
 
+// ExecStream executes a command in the shell with streaming output to provided writers
+func (s *Shell) ExecStream(ctx context.Context, command string, stdout, stderr io.Writer) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	return s.execStream(ctx, command, stdout, stderr)
+}
+
 // GetWorkingDir returns the current working directory
 func (s *Shell) GetWorkingDir() string {
 	s.mu.Lock()
@@ -228,34 +236,56 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand
 	}
 }
 
-// exec executes commands using a cross-platform shell interpreter.
-func (s *Shell) exec(ctx context.Context, command string) (string, string, error) {
-	line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
-	if err != nil {
-		return "", "", fmt.Errorf("could not parse command: %w", err)
-	}
-
-	var stdout, stderr bytes.Buffer
-	runner, err := interp.New(
-		interp.StdIO(nil, &stdout, &stderr),
+// newInterp creates a new interpreter with the current shell state
+func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
+	return interp.New(
+		interp.StdIO(nil, stdout, stderr),
 		interp.Interactive(false),
 		interp.Env(expand.ListEnviron(s.env...)),
 		interp.Dir(s.cwd),
 		interp.ExecHandlers(s.execHandlers()...),
 	)
-	if err != nil {
-		return "", "", fmt.Errorf("could not run command: %w", err)
-	}
+}
 
-	err = runner.Run(ctx, line)
+// updateShellFromRunner updates the shell from the interpreter after execution
+func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
 	s.cwd = runner.Dir
+	s.env = nil
 	for name, vr := range runner.Vars {
 		s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
 	}
+}
+
+// execCommon is the shared implementation for executing commands
+func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) error {
+	line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
+	if err != nil {
+		return fmt.Errorf("could not parse command: %w", err)
+	}
+
+	runner, err := s.newInterp(stdout, stderr)
+	if err != nil {
+		return fmt.Errorf("could not run command: %w", err)
+	}
+
+	err = runner.Run(ctx, line)
+	s.updateShellFromRunner(runner)
 	s.logger.InfoPersist("command finished", "command", command, "err", err)
+	return err
+}
+
+// exec executes commands using a cross-platform shell interpreter.
+func (s *Shell) exec(ctx context.Context, command string) (string, string, error) {
+	var stdout, stderr bytes.Buffer
+	err := s.execCommon(ctx, command, &stdout, &stderr)
 	return stdout.String(), stderr.String(), err
 }
 
+// execStream executes commands using POSIX shell emulation with streaming output
+func (s *Shell) execStream(ctx context.Context, command string, stdout, stderr io.Writer) error {
+	return s.execCommon(ctx, command, stdout, stderr)
+}
+
 func (s *Shell) execHandlers() []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
 	handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
 		s.blockHandler(),

+ 157 - 1
internal/tui/components/chat/messages/renderer.go

@@ -1,6 +1,7 @@
 package messages
 
 import (
+	"cmp"
 	"encoding/json"
 	"fmt"
 	"strings"
@@ -163,6 +164,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.JobOutputToolName, func() renderer { return bashOutputRenderer{} })
+	registry.register(tools.JobKillToolName, 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 +216,31 @@ 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.RunInBackground).
+		build()
+	if v.call.Finished {
+		var meta tools.BashResponseMetadata
+		_ = br.unmarshalParams(v.result.Metadata, &meta)
+		if meta.Background {
+			description := cmp.Or(meta.Description, params.Command)
+			width := v.textWidth()
+			if v.isNested {
+				width -= 4 // Adjust for nested tool call indentation
+			}
+			header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
+			if v.isNested {
+				return v.style().Render(header)
+			}
+			if res, done := earlyState(header, v); done {
+				return res
+			}
+			content := "Command: " + params.Command + "\n" + v.result.Content
+			body := renderPlainContent(v, content)
+			return joinHeaderBody(header, body)
+		}
+	}
 
 	return br.renderWithParams(v, "Bash", args, func() string {
 		var meta tools.BashResponseMetadata
@@ -232,6 +259,131 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
 	})
 }
 
+// -----------------------------------------------------------------------------
+//  Bash Output renderer
+// -----------------------------------------------------------------------------
+
+func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string {
+	t := styles.CurrentTheme()
+	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
+	if v.result.ToolCallID != "" {
+		if v.result.IsError {
+			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
+		} else {
+			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
+		}
+	} else if v.cancelled {
+		icon = t.S().Muted.Render(styles.ToolPending)
+	}
+
+	jobPart := t.S().Base.Foreground(t.Blue).Render("Job")
+	subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")")
+	pidPart := t.S().Muted.Render(pid)
+	descPart := ""
+	if description != "" {
+		descPart = " " + t.S().Subtle.Render(description)
+	}
+
+	// Build the complete header
+	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart)
+	fullHeader := prefix + descPart
+
+	// Truncate if needed
+	if lipgloss.Width(fullHeader) > width {
+		availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space
+		if availableWidth < 10 {
+			// Not enough space for description, just show prefix
+			return prefix
+		}
+		descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "…"))
+		fullHeader = prefix + descPart
+	}
+
+	return fullHeader
+}
+
+// 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.JobOutputParams
+	if err := bor.unmarshalParams(v.call.Input, &params); err != nil {
+		return bor.renderError(v, "Invalid job_output parameters")
+	}
+
+	var meta tools.JobOutputResponseMetadata
+	var description string
+	if v.result.Metadata != "" {
+		if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil {
+			if meta.Description != "" {
+				description = meta.Description
+			} else {
+				description = meta.Command
+			}
+		}
+	}
+
+	width := v.textWidth()
+	if v.isNested {
+		width -= 4 // Adjust for nested tool call indentation
+	}
+	header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
+	if v.isNested {
+		return v.style().Render(header)
+	}
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  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.JobKillParams
+	if err := bkr.unmarshalParams(v.call.Input, &params); err != nil {
+		return bkr.renderError(v, "Invalid job_kill parameters")
+	}
+
+	var meta tools.JobKillResponseMetadata
+	var description string
+	if v.result.Metadata != "" {
+		if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil {
+			if meta.Description != "" {
+				description = meta.Description
+			} else {
+				description = meta.Command
+			}
+		}
+	}
+
+	width := v.textWidth()
+	if v.isNested {
+		width -= 4 // Adjust for nested tool call indentation
+	}
+	header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
+	if v.isNested {
+		return v.style().Render(header)
+	}
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
 // -----------------------------------------------------------------------------
 //  View renderer
 // -----------------------------------------------------------------------------
@@ -1013,6 +1165,10 @@ func prettifyToolName(name string) string {
 		return "Agent"
 	case tools.BashToolName:
 		return "Bash"
+	case tools.JobOutputToolName:
+		return "Job: Output"
+	case tools.JobKillToolName:
+		return "Job: Kill"
 	case tools.DownloadToolName:
 		return "Download"
 	case tools.EditToolName:

+ 34 - 6
internal/tui/components/dialogs/permissions/permissions.go

@@ -291,19 +291,30 @@ func (p *permissionDialogCmp) renderHeader() string {
 			toolKey,
 			toolValue,
 		),
-		baseStyle.Render(strings.Repeat(" ", p.width)),
 		lipgloss.JoinHorizontal(
 			lipgloss.Left,
 			pathKey,
 			pathValue,
 		),
-		baseStyle.Render(strings.Repeat(" ", p.width)),
 	}
 
 	// Add tool-specific header information
 	switch p.permission.ToolName {
 	case tools.BashToolName:
-		headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
+		params := p.permission.Params.(tools.BashPermissionsParams)
+		descKey := t.S().Muted.Render("Desc")
+		descValue := t.S().Text.
+			Width(p.width - lipgloss.Width(descKey)).
+			Render(fmt.Sprintf(" %s", params.Description))
+		headerParts = append(headerParts,
+			lipgloss.JoinHorizontal(
+				lipgloss.Left,
+				descKey,
+				descValue,
+			),
+			baseStyle.Render(strings.Repeat(" ", p.width)),
+			t.S().Muted.Width(p.width).Render("Command"),
+		)
 	case tools.DownloadToolName:
 		params := p.permission.Params.(tools.DownloadPermissionsParams)
 		urlKey := t.S().Muted.Render("URL")
@@ -320,7 +331,6 @@ func (p *permissionDialogCmp) renderHeader() string {
 				urlKey,
 				urlValue,
 			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
 			lipgloss.JoinHorizontal(
 				lipgloss.Left,
 				fileKey,
@@ -372,9 +382,15 @@ func (p *permissionDialogCmp) renderHeader() string {
 			baseStyle.Render(strings.Repeat(" ", p.width)),
 		)
 	case tools.FetchToolName:
-		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
+		headerParts = append(headerParts,
+			baseStyle.Render(strings.Repeat(" ", p.width)),
+			t.S().Muted.Width(p.width).Bold(true).Render("URL"),
+		)
 	case tools.AgenticFetchToolName:
-		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
+		headerParts = append(headerParts,
+			baseStyle.Render(strings.Repeat(" ", p.width)),
+			t.S().Muted.Width(p.width).Bold(true).Render("URL"),
+		)
 	case tools.ViewToolName:
 		params := p.permission.Params.(tools.ViewPermissionsParams)
 		fileKey := t.S().Muted.Render("File")
@@ -466,6 +482,17 @@ func (p *permissionDialogCmp) generateBashContent() string {
 				Render(ln))
 		}
 
+		// Ensure minimum of 7 lines for command display
+		minLines := 7
+		for len(out) < minLines {
+			out = append(out, t.S().Muted.
+				Width(width).
+				Padding(0, 3).
+				Foreground(t.FgBase).
+				Background(t.BgSubtle).
+				Render(""))
+		}
+
 		// Use the cache for markdown rendering
 		renderedContent := strings.Join(out, "\n")
 		finalContent := baseStyle.
@@ -741,6 +768,7 @@ func (p *permissionDialogCmp) render() string {
 		title,
 		"",
 		headerContent,
+		"",
 		p.styleViewport(),
 		"",
 		buttons,

+ 1 - 0
internal/tui/styles/charmtone.go

@@ -42,6 +42,7 @@ func NewCharmtoneTheme() *Theme {
 		White: charmtone.Butter,
 
 		BlueLight: charmtone.Sardine,
+		BlueDark:  charmtone.Damson,
 		Blue:      charmtone.Malibu,
 
 		Yellow: charmtone.Mustard,

+ 1 - 0
internal/tui/styles/theme.go

@@ -58,6 +58,7 @@ type Theme struct {
 
 	// Blues
 	BlueLight color.Color
+	BlueDark  color.Color
 	Blue      color.Color
 
 	// Yellows

部分文件因为文件数量过多而无法显示