claudetool: replace timeout parameter with slow_ok boolean
Empirically, the agent doesn't set timeouts long enough,
and doesn't retry on failure.
Give it only one decision to make: Is this maybe a slow command?
If, horror of horrors, your project can't accomplish tasks within the
default timeouts, there's a new command line flag to adjust them.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sc26e3516f28c22d4k
diff --git a/claudetool/bash.go b/claudetool/bash.go
index a62892f..59b8e3c 100644
--- a/claudetool/bash.go
+++ b/claudetool/bash.go
@@ -23,44 +23,77 @@
// PermissionCallback is a function type for checking if a command is allowed to run
type PermissionCallback func(command string) error
-// BashTool is a struct for executing shell commands with bash -c and optional timeout
+// BashTool specifies a llm.Tool for executing shell commands.
type BashTool struct {
// CheckPermission is called before running any command, if set
CheckPermission PermissionCallback
// EnableJITInstall enables just-in-time tool installation for missing commands
EnableJITInstall bool
+ // Timeouts holds the configurable timeout values (uses defaults if nil)
+ Timeouts *Timeouts
}
const (
EnableBashToolJITInstall = true
NoBashToolJITInstall = false
+
+ DefaultFastTimeout = 30 * time.Second
+ DefaultSlowTimeout = 15 * time.Minute
+ DefaultBackgroundTimeout = 24 * time.Hour
)
-// NewBashTool creates a new Bash tool with optional permission callback
-func NewBashTool(checkPermission PermissionCallback, enableJITInstall bool) *llm.Tool {
- tool := &BashTool{
- CheckPermission: checkPermission,
- EnableJITInstall: enableJITInstall,
- }
+// Timeouts holds the configurable timeout values for bash commands.
+type Timeouts struct {
+ Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
+ Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
+ Background time.Duration // background commands (e.g., servers, long-running processes)
+}
+// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
+func (t *Timeouts) fast() time.Duration {
+ if t == nil {
+ return DefaultFastTimeout
+ }
+ return t.Fast
+}
+
+// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
+func (t *Timeouts) slow() time.Duration {
+ if t == nil {
+ return DefaultSlowTimeout
+ }
+ return t.Slow
+}
+
+// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
+func (t *Timeouts) background() time.Duration {
+ if t == nil {
+ return DefaultBackgroundTimeout
+ }
+ return t.Background
+}
+
+// Tool returns an llm.Tool based on b.
+func (b *BashTool) Tool() *llm.Tool {
return &llm.Tool{
Name: bashName,
Description: strings.TrimSpace(bashDescription),
InputSchema: llm.MustSchema(bashInputSchema),
- Run: tool.Run,
+ Run: b.Run,
}
}
-// The Bash tool executes shell commands with bash -c and optional timeout
-var Bash = NewBashTool(nil, NoBashToolJITInstall)
-
const (
bashName = "bash"
bashDescription = `
-Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
-When run with background flag, the process may keep running after the tool call returns, and
-the agent can inspect the output by reading the output files. Use the background task when, for example,
-starting a server to test something. Be sure to kill the process group when done.
+Executes shell commands via bash -c, returning combined stdout/stderr.
+
+With background=true, returns immediately while process continues running
+with output redirected to files. Kill process group when done.
+Use background for servers/demos that need to stay running.
+
+MUST set slow_ok=true for potentially slow commands: builds, downloads,
+installs, tests, or any other substantive operation.
`
// If you modify this, update the termui template for prettier rendering.
bashInputSchema = `
@@ -70,15 +103,15 @@
"properties": {
"command": {
"type": "string",
- "description": "Shell script to execute"
+ "description": "Shell to execute"
},
- "timeout": {
- "type": "string",
- "description": "Timeout as a Go duration string, defaults to 10s if background is false; 10m if background is true"
+ "slow_ok": {
+ "type": "boolean",
+ "description": "Use extended timeout"
},
"background": {
"type": "boolean",
- "description": "If true, executes the command in the background without waiting for completion"
+ "description": "Execute in background"
}
}
}
@@ -87,7 +120,7 @@
type bashInput struct {
Command string `json:"command"`
- Timeout string `json:"timeout,omitempty"`
+ SlowOK bool `json:"slow_ok,omitempty"`
Background bool `json:"background,omitempty"`
}
@@ -97,19 +130,14 @@
StderrFile string `json:"stderr_file"`
}
-func (i *bashInput) timeout() time.Duration {
- if i.Timeout != "" {
- dur, err := time.ParseDuration(i.Timeout)
- if err == nil {
- return dur
- }
- }
-
- // Otherwise, use different defaults based on background mode
- if i.Background {
- return 10 * time.Minute
- } else {
- return 10 * time.Second
+func (i *bashInput) timeout(t *Timeouts) time.Duration {
+ switch {
+ case i.Background:
+ return t.background()
+ case i.SlowOK:
+ return t.slow()
+ default:
+ return t.fast()
}
}
@@ -140,9 +168,11 @@
}
}
+ timeout := req.timeout(b.Timeouts)
+
// If Background is set to true, use executeBackgroundBash
if req.Background {
- result, err := executeBackgroundBash(ctx, req)
+ result, err := executeBackgroundBash(ctx, req, timeout)
if err != nil {
return nil, err
}
@@ -156,7 +186,7 @@
}
// For foreground commands, use executeBash
- out, execErr := executeBash(ctx, req)
+ out, execErr := executeBash(ctx, req, timeout)
if execErr != nil {
return nil, execErr
}
@@ -165,8 +195,8 @@
const maxBashOutputLength = 131072
-func executeBash(ctx context.Context, req bashInput) (string, error) {
- execCtx, cancel := context.WithTimeout(ctx, req.timeout())
+func executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
+ execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
@@ -218,7 +248,7 @@
if len(partialOutput) > maxBashOutputLength {
partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
}
- return "", fmt.Errorf("command timed out after %s\nCommand output (until it timed out):\n%s", req.timeout(), outstr)
+ return "", fmt.Errorf("command timed out after %s\nCommand output (until it timed out):\n%s", timeout, outstr)
}
if err != nil {
return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
@@ -246,7 +276,7 @@
}
// executeBackgroundBash executes a command in the background and returns the pid and output file locations
-func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
+func executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
// Create temporary directory for output files
tmpDir, err := os.MkdirTemp("", "sketch-bg-")
if err != nil {
@@ -296,7 +326,6 @@
// Set up timeout handling if a timeout was specified
pid := cmd.Process.Pid
- timeout := req.timeout()
if timeout > 0 {
// Launch a goroutine that will kill the process after the timeout
go func() {
@@ -377,7 +406,7 @@
}
subConvo := info.Convo.SubConvo()
subConvo.Hidden = true
- subBash := NewBashTool(nil, NoBashToolJITInstall)
+ subBash := &BashTool{EnableJITInstall: NoBashToolJITInstall}
done := false
doneTool := &llm.Tool{
@@ -428,7 +457,7 @@
}
subConvo.Tools = []*llm.Tool{
- subBash,
+ subBash.Tool(),
doneTool,
}
diff --git a/claudetool/bash_test.go b/claudetool/bash_test.go
index 6904447..98410d5 100644
--- a/claudetool/bash_test.go
+++ b/claudetool/bash_test.go
@@ -11,12 +11,60 @@
"time"
)
+func TestBashSlowOk(t *testing.T) {
+ // Test that slow_ok flag is properly handled
+ t.Run("SlowOk Flag", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"echo 'slow test'","slow_ok":true}`)
+
+ bashTool := (&BashTool{}).Tool()
+ result, err := bashTool.Run(context.Background(), input)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ expected := "slow test\n"
+ if len(result) == 0 || result[0].Text != expected {
+ t.Errorf("Expected %q, got %q", expected, result[0].Text)
+ }
+ })
+
+ // Test that slow_ok with background works
+ t.Run("SlowOk with Background", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"echo 'slow background test'","slow_ok":true,"background":true}`)
+
+ bashTool := (&BashTool{}).Tool()
+ result, err := bashTool.Run(context.Background(), input)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ // Should return background result JSON
+ var bgResult BackgroundResult
+ resultStr := result[0].Text
+ if err := json.Unmarshal([]byte(resultStr), &bgResult); err != nil {
+ t.Fatalf("Failed to unmarshal background result: %v", err)
+ }
+
+ if bgResult.PID <= 0 {
+ t.Errorf("Invalid PID returned: %d", bgResult.PID)
+ }
+
+ // Clean up
+ os.Remove(bgResult.StdoutFile)
+ os.Remove(bgResult.StderrFile)
+ os.Remove(filepath.Dir(bgResult.StdoutFile))
+ })
+}
+
func TestBashTool(t *testing.T) {
+ var bashTool BashTool
+ tool := bashTool.Tool()
+
// Test basic functionality
t.Run("Basic Command", func(t *testing.T) {
input := json.RawMessage(`{"command":"echo 'Hello, world!'"}`)
- result, err := Bash.Run(context.Background(), input)
+ result, err := tool.Run(context.Background(), input)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -31,7 +79,7 @@
t.Run("Command With Arguments", func(t *testing.T) {
input := json.RawMessage(`{"command":"echo -n foo && echo -n bar"}`)
- result, err := Bash.Run(context.Background(), input)
+ result, err := tool.Run(context.Background(), input)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -42,21 +90,21 @@
}
})
- // Test with timeout parameter
- t.Run("With Timeout", func(t *testing.T) {
+ // Test with slow_ok parameter
+ t.Run("With SlowOK", func(t *testing.T) {
inputObj := struct {
Command string `json:"command"`
- Timeout string `json:"timeout"`
+ SlowOK bool `json:"slow_ok"`
}{
Command: "sleep 0.1 && echo 'Completed'",
- Timeout: "5s",
+ SlowOK: true,
}
inputJSON, err := json.Marshal(inputObj)
if err != nil {
t.Fatalf("Failed to marshal input: %v", err)
}
- result, err := Bash.Run(context.Background(), inputJSON)
+ result, err := tool.Run(context.Background(), inputJSON)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -67,21 +115,22 @@
}
})
- // Test command timeout
+ // Test command timeout with custom timeout config
t.Run("Command Timeout", func(t *testing.T) {
- inputObj := struct {
- Command string `json:"command"`
- Timeout string `json:"timeout"`
- }{
- Command: "sleep 0.5 && echo 'Should not see this'",
- Timeout: "100ms",
+ // Use a custom BashTool with very short timeout
+ customTimeouts := &Timeouts{
+ Fast: 100 * time.Millisecond,
+ Slow: 100 * time.Millisecond,
+ Background: 100 * time.Millisecond,
}
- inputJSON, err := json.Marshal(inputObj)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
+ customBash := &BashTool{
+ Timeouts: customTimeouts,
}
+ tool := customBash.Tool()
- _, err = Bash.Run(context.Background(), inputJSON)
+ input := json.RawMessage(`{"command":"sleep 0.5 && echo 'Should not see this'"}`)
+
+ _, err := tool.Run(context.Background(), input)
if err == nil {
t.Errorf("Expected timeout error, got none")
} else if !strings.Contains(err.Error(), "timed out") {
@@ -93,7 +142,7 @@
t.Run("Failed Command", func(t *testing.T) {
input := json.RawMessage(`{"command":"exit 1"}`)
- _, err := Bash.Run(context.Background(), input)
+ _, err := tool.Run(context.Background(), input)
if err == nil {
t.Errorf("Expected error for failed command, got none")
}
@@ -103,7 +152,7 @@
t.Run("Invalid JSON Input", func(t *testing.T) {
input := json.RawMessage(`{"command":123}`) // Invalid JSON (command must be string)
- _, err := Bash.Run(context.Background(), input)
+ _, err := tool.Run(context.Background(), input)
if err == nil {
t.Errorf("Expected error for invalid input, got none")
}
@@ -117,10 +166,9 @@
t.Run("Successful Command", func(t *testing.T) {
req := bashInput{
Command: "echo 'Success'",
- Timeout: "5s",
}
- output, err := executeBash(ctx, req)
+ output, err := executeBash(ctx, req, 5*time.Second)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -135,10 +183,9 @@
t.Run("SKETCH Environment Variable", func(t *testing.T) {
req := bashInput{
Command: "echo $SKETCH",
- Timeout: "5s",
}
- output, err := executeBash(ctx, req)
+ output, err := executeBash(ctx, req, 5*time.Second)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -153,10 +200,9 @@
t.Run("Command with stderr", func(t *testing.T) {
req := bashInput{
Command: "echo 'Error message' >&2 && echo 'Success'",
- Timeout: "5s",
}
- output, err := executeBash(ctx, req)
+ output, err := executeBash(ctx, req, 5*time.Second)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -171,10 +217,9 @@
t.Run("Failed Command with stderr", func(t *testing.T) {
req := bashInput{
Command: "echo 'Error message' >&2 && exit 1",
- Timeout: "5s",
}
- _, err := executeBash(ctx, req)
+ _, err := executeBash(ctx, req, 5*time.Second)
if err == nil {
t.Errorf("Expected error for failed command, got none")
} else if !strings.Contains(err.Error(), "Error message") {
@@ -186,11 +231,10 @@
t.Run("Command Timeout", func(t *testing.T) {
req := bashInput{
Command: "sleep 1 && echo 'Should not see this'",
- Timeout: "100ms",
}
start := time.Now()
- _, err := executeBash(ctx, req)
+ _, err := executeBash(ctx, req, 100*time.Millisecond)
elapsed := time.Since(start)
// Command should time out after ~100ms, not wait for full 1 second
@@ -207,6 +251,9 @@
}
func TestBackgroundBash(t *testing.T) {
+ var bashTool BashTool
+ tool := bashTool.Tool()
+
// Test basic background execution
t.Run("Basic Background Command", func(t *testing.T) {
inputObj := struct {
@@ -221,7 +268,7 @@
t.Fatalf("Failed to marshal input: %v", err)
}
- result, err := Bash.Run(context.Background(), inputJSON)
+ result, err := tool.Run(context.Background(), inputJSON)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -279,7 +326,7 @@
t.Fatalf("Failed to marshal input: %v", err)
}
- result, err := Bash.Run(context.Background(), inputJSON)
+ result, err := tool.Run(context.Background(), inputJSON)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -337,7 +384,7 @@
}
// Start the command in the background
- result, err := Bash.Run(context.Background(), inputJSON)
+ result, err := tool.Run(context.Background(), inputJSON)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@@ -390,8 +437,8 @@
Command: "echo 'test'",
Background: false,
}
- fgTimeout := foreground.timeout()
- expectedFg := 10 * time.Second
+ fgTimeout := foreground.timeout(nil)
+ expectedFg := 30 * time.Second
if fgTimeout != expectedFg {
t.Errorf("Expected foreground default timeout to be %v, got %v", expectedFg, fgTimeout)
}
@@ -401,22 +448,38 @@
Command: "echo 'test'",
Background: true,
}
- bgTimeout := background.timeout()
- expectedBg := 10 * time.Minute
+ bgTimeout := background.timeout(nil)
+ expectedBg := 24 * time.Hour
if bgTimeout != expectedBg {
t.Errorf("Expected background default timeout to be %v, got %v", expectedBg, bgTimeout)
}
- // Test explicit timeout overrides defaults
- explicit := bashInput{
+ // Test slow_ok timeout
+ slowOk := bashInput{
Command: "echo 'test'",
- Background: true,
- Timeout: "5s",
+ Background: false,
+ SlowOK: true,
}
- explicitTimeout := explicit.timeout()
- expectedExplicit := 5 * time.Second
- if explicitTimeout != expectedExplicit {
- t.Errorf("Expected explicit timeout to be %v, got %v", expectedExplicit, explicitTimeout)
+ slowTimeout := slowOk.timeout(nil)
+ expectedSlow := 15 * time.Minute
+ if slowTimeout != expectedSlow {
+ t.Errorf("Expected slow_ok timeout to be %v, got %v", expectedSlow, slowTimeout)
+ }
+
+ // Test custom timeout config
+ customTimeouts := &Timeouts{
+ Fast: 5 * time.Second,
+ Slow: 2 * time.Minute,
+ Background: 1 * time.Hour,
+ }
+ customFast := bashInput{
+ Command: "echo 'test'",
+ Background: false,
+ }
+ customTimeout := customFast.timeout(customTimeouts)
+ expectedCustom := 5 * time.Second
+ if customTimeout != expectedCustom {
+ t.Errorf("Expected custom timeout to be %v, got %v", expectedCustom, customTimeout)
}
})
}
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 9d1d571..dc21886 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -19,9 +19,11 @@
"runtime/debug"
"strings"
"syscall"
+ "time"
"golang.org/x/term"
"sketch.dev/browser"
+ "sketch.dev/claudetool"
"sketch.dev/dockerimg"
"sketch.dev/experiment"
"sketch.dev/llm"
@@ -247,6 +249,10 @@
sshConnectionString string
subtraceToken string
mcpServers StringSliceFlag
+ // Timeout configuration for bash tool
+ bashFastTimeout string
+ bashSlowTimeout string
+ bashBackgroundTimeout string
}
// parseCLIFlags parses all command-line flags and returns a CLIFlags struct
@@ -285,6 +291,9 @@
userFlags.StringVar(&flags.branchPrefix, "branch-prefix", "sketch/", "prefix for git branches created by sketch")
userFlags.BoolVar(&flags.ignoreSig, "ignoresig", false, "ignore typical termination signals (SIGINT, SIGTERM)")
userFlags.Var(&flags.mcpServers, "mcp", "MCP server configuration as JSON (can be repeated). Schema: {\"name\": \"server-name\", \"type\": \"stdio|http|sse\", \"url\": \"...\", \"command\": \"...\", \"args\": [...], \"env\": {...}, \"headers\": {...}}")
+ userFlags.StringVar(&flags.bashFastTimeout, "bash-fast-timeout", "30s", "timeout for fast bash commands")
+ userFlags.StringVar(&flags.bashSlowTimeout, "bash-slow-timeout", "10m", "timeout for slow bash commands (downloads, builds, tests)")
+ userFlags.StringVar(&flags.bashBackgroundTimeout, "bash-background-timeout", "24h", "timeout for background bash commands")
// Internal flags (for sketch developers or internal use)
// Args to sketch innie:
@@ -590,6 +599,25 @@
MCPServers: flags.mcpServers,
}
+ // Parse timeout configuration
+ var bashTimeouts claudetool.Timeouts
+ if dur, err := time.ParseDuration(flags.bashFastTimeout); err == nil {
+ bashTimeouts.Fast = dur
+ } else {
+ bashTimeouts.Fast = claudetool.DefaultFastTimeout
+ }
+ if dur, err := time.ParseDuration(flags.bashSlowTimeout); err == nil {
+ bashTimeouts.Slow = dur
+ } else {
+ bashTimeouts.Slow = claudetool.DefaultSlowTimeout
+ }
+ if dur, err := time.ParseDuration(flags.bashBackgroundTimeout); err == nil {
+ bashTimeouts.Background = dur
+ } else {
+ bashTimeouts.Background = claudetool.DefaultBackgroundTimeout
+ }
+ agentConfig.BashTimeouts = &bashTimeouts
+
// Create SkabandClient if skaband address is provided
if flags.skabandAddr != "" && pubKey != "" {
agentConfig.SkabandClient = skabandclient.NewSkabandClient(flags.skabandAddr, pubKey)
diff --git a/loop/agent.go b/loop/agent.go
index 82c3268..7a8fb8c 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -1060,6 +1060,8 @@
SkabandClient *skabandclient.SkabandClient
// MCP server configurations
MCPServers []string
+ // Timeout configuration for bash tool
+ BashTimeouts *claudetool.Timeouts
}
// NewAgent creates a new Agent.
@@ -1294,7 +1296,11 @@
return nil
}
- bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
+ bashTool := &claudetool.BashTool{
+ CheckPermission: bashPermissionCheck,
+ EnableJITInstall: claudetool.EnableBashToolJITInstall,
+ Timeouts: a.config.BashTimeouts,
+ }
// Register all tools with the conversation
// When adding, removing, or modifying tools here, double-check that the termui tool display
@@ -1314,7 +1320,7 @@
browserTools = bTools
convo.Tools = []*llm.Tool{
- bashTool, claudetool.Keyword, claudetool.Patch(a.patchCallback),
+ bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
a.codereview.Tool(), claudetool.AboutSketch,
}
diff --git a/loop/testdata/agent_loop.httprr b/loop/testdata/agent_loop.httprr
index 49e76da..25aca32 100644
--- a/loop/testdata/agent_loop.httprr
+++ b/loop/testdata/agent_loop.httprr
@@ -1,9 +1,9 @@
httprr trace v1
-20692 2587
+20567 2526
POST https://api.anthropic.com/v1/messages HTTP/1.1
Host: api.anthropic.com
User-Agent: Go-http-client/1.1
-Content-Length: 20494
+Content-Length: 20369
Anthropic-Version: 2023-06-01
Content-Type: application/json
@@ -27,7 +27,7 @@
"tools": [
{
"name": "bash",
- "description": "Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.\nWhen run with background flag, the process may keep running after the tool call returns, and\nthe agent can inspect the output by reading the output files. Use the background task when, for example,\nstarting a server to test something. Be sure to kill the process group when done.",
+ "description": "Executes shell commands via bash -c, returning combined stdout/stderr.\n\nWith background=true, returns immediately while process continues running\nwith output redirected to files. Kill process group when done.\nUse background for servers/demos that need to stay running.\n\nMUST set slow_ok=true for potentially slow commands: builds, downloads,\ninstalls, tests, or any other substantive operation.",
"input_schema": {
"type": "object",
"required": [
@@ -36,15 +36,15 @@
"properties": {
"command": {
"type": "string",
- "description": "Shell script to execute"
+ "description": "Shell to execute"
},
- "timeout": {
- "type": "string",
- "description": "Timeout as a Go duration string, defaults to 10s if background is false; 10m if background is true"
+ "slow_ok": {
+ "type": "boolean",
+ "description": "Use extended timeout"
},
"background": {
"type": "boolean",
- "description": "If true, executes the command in the background without waiting for completion"
+ "description": "Execute in background"
}
}
}
@@ -602,25 +602,25 @@
}HTTP/2.0 200 OK
Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-02T22:43:53Z
+Anthropic-Ratelimit-Input-Tokens-Remaining: 184000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-11T01:09:41Z
Anthropic-Ratelimit-Output-Tokens-Limit: 80000
Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-02T22:44:03Z
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-11T01:09:42Z
Anthropic-Ratelimit-Requests-Limit: 4000
Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-07-02T22:43:47Z
+Anthropic-Ratelimit-Requests-Reset: 2025-07-11T01:09:33Z
Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-07-02T22:43:53Z
+Anthropic-Ratelimit-Tokens-Remaining: 264000
+Anthropic-Ratelimit-Tokens-Reset: 2025-07-11T01:09:41Z
Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9591a9fb9af39e58-SJC
+Cf-Ray: 95d46a83bdfc16a2-SJC
Content-Type: application/json
-Date: Wed, 02 Jul 2025 22:44:03 GMT
-Request-Id: req_011CQj12QtwdhXutWELw6Xs3
+Date: Fri, 11 Jul 2025 01:09:42 GMT
+Request-Id: req_011CQzLcTmJPByqxoE27HKVH
Server: cloudflare
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Via: 1.1 google
X-Robots-Tag: none
-{"id":"msg_01UtTaMqbCD6qFYqz7s8w1Yi","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Management:**\n- `bash` - Execute shell commands\n- `patch` - Modify files with precise text edits\n- `keyword_search` - Search codebase by keywords\n\n**Task Management:**\n- `todo_read` - Read current todo list\n- `todo_write` - Create and manage task lists\n- `think` - Record thoughts and plans\n\n**Git & Code Quality:**\n- `commit-message-style` - Get git commit message guidance\n- `codereview` - Run automated code review\n- `done` - Complete work with verification checklist\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type text into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read element text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n\n**Utility:**\n- `set-slug` - Set conversation identifier\n- `about_sketch` - Get Sketch platform help\n- `multiplechoice` - Present multiple choice questions\n- `read_image` - Read and encode image files"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4740,"cache_read_input_tokens":0,"output_tokens":342,"service_tier":"standard"}}
\ No newline at end of file
+{"id":"msg_019kDFP5RwG2yYoYeTuPLXzZ","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Management:**\n- `bash` - Execute shell commands\n- `patch` - Modify files with precise text edits\n- `keyword_search` - Search for files in unfamiliar codebases\n\n**Planning & Organization:**\n- `think` - Take notes and form plans\n- `todo_read` / `todo_write` - Manage task lists\n- `done` - Complete work with verification checklist\n\n**Git & Code Review:**\n- `commit-message-style` - Get commit message guidance\n- `codereview` - Run automated code review\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n\n**Utilities:**\n- `read_image` - Read and encode image files\n- `about_sketch` - Get help with Sketch functionality\n- `multiplechoice` - Present multiple choice questions"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4719,"cache_read_input_tokens":0,"output_tokens":329,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/termui/termui.go b/termui/termui.go
index 0fa8d8d..2b792ad 100644
--- a/termui/termui.go
+++ b/termui/termui.go
@@ -40,7 +40,7 @@
{{else if eq .msg.ToolName "keyword_search" -}}
🔍 {{ .input.query}}: {{.input.search_terms -}}
{{else if eq .msg.ToolName "bash" -}}
- 🖥️{{if .input.background}}🔄{{end}} {{ .input.command -}}
+ 🖥️ {{if .input.background}}🥷 {{end}}{{if .input.slow_ok}}🐢 {{end}}{{ .input.command -}}
{{else if eq .msg.ToolName "patch" -}}
⌨️ {{.input.path -}}
{{else if eq .msg.ToolName "done" -}}
diff --git a/test/timeout_test.go b/test/timeout_test.go
deleted file mode 100644
index 64c4158..0000000
--- a/test/timeout_test.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package test
-
-import (
- "context"
- "encoding/json"
- "testing"
-
- "sketch.dev/claudetool"
-)
-
-func TestBashTimeout(t *testing.T) {
- // Create a bash tool
- bashTool := claudetool.NewBashTool(nil, claudetool.NoBashToolJITInstall)
-
- // Create a command that will output text and then sleep
- cmd := `echo "Starting command..."; echo "This should appear in partial output"; sleep 5; echo "This shouldn't appear"`
-
- // Prepare the input with a very short timeout
- input := map[string]any{
- "command": cmd,
- "timeout": "1s", // Very short timeout to trigger the timeout case
- }
-
- // Marshal the input to JSON
- inputJSON, err := json.Marshal(input)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- // Run the bash tool
- ctx := context.Background()
- result, err := bashTool.Run(ctx, inputJSON)
-
- // Check that we got an error (due to timeout)
- if err == nil {
- t.Fatalf("Expected timeout error, got nil")
- }
-
- // Error should mention timeout
- if !containsString(err.Error(), "timed out") {
- t.Errorf("Error doesn't mention timeout: %v", err)
- }
-
- // No output should be returned directly, it should be in the error message
- if len(result) > 0 {
- t.Fatalf("Expected no direct output, got: %v", result)
- }
-
- // The error should contain the partial output
- errorMsg := err.Error()
- if !containsString(errorMsg, "Starting command") || !containsString(errorMsg, "should appear in partial output") {
- t.Errorf("Error should contain the partial output: %v", errorMsg)
- }
-
- // The error should indicate a timeout
- if !containsString(errorMsg, "timed out") {
- t.Errorf("Error should indicate a timeout: %v", errorMsg)
- }
-
- // The error should not contain the output that would appear after the sleep
- if containsString(err.Error(), "shouldn't appear") {
- t.Errorf("Error contains output that should not have been captured (after timeout): %s", err.Error())
- }
-}
-
-func containsString(s, substr string) bool {
- return s != "" && s != "<nil>" && stringIndexOf(s, substr) >= 0
-}
-
-func stringIndexOf(s, substr string) int {
- for i := 0; i <= len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return i
- }
- }
- return -1
-}
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
index 34e9020..33fa050 100644
--- a/webui/src/web-components/sketch-tool-card.ts
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -385,7 +385,9 @@
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
const isBackground = inputData?.background === true;
- const backgroundIcon = isBackground ? "🔄 " : "";
+ const isSlowOk = inputData?.slow_ok === true;
+ const backgroundIcon = isBackground ? "🥷 " : "";
+ const slowIcon = isSlowOk ? "🐢 " : "";
// Truncate the command if it's too long to display nicely
const command = inputData?.command || "";
@@ -405,12 +407,12 @@
class="command-wrapper"
style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
>
- ${backgroundIcon}${displayCommand}
+ ${backgroundIcon}${slowIcon}${displayCommand}
</div>
</span>
<div slot="input" class="input">
<div class="tool-call-result-container">
- <pre>${backgroundIcon}${inputData?.command}</pre>
+ <pre>${backgroundIcon}${slowIcon}${inputData?.command}</pre>
</div>
</div>
${this.toolCall?.result_message?.tool_result