shelley: set SHELLEY_CONVERSATION_ID for bash commands

Philip Zeyliger and Shelley created

Prompt: Make the bash tool set SHELLEY_CONVERSATION_ID for things it invokes.

Add ConversationID field to ToolSetConfig and BashTool. When
ConversationID is set, the bash tool will expose it to invoked
commands via the SHELLEY_CONVERSATION_ID environment variable.

This allows scripts and commands run by Shelley to know which
conversation they're being invoked from, enabling things like
logging, debugging, or conversation-aware behaviors.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/bash.go      |  6 ++++++
claudetool/bash_test.go | 39 +++++++++++++++++++++++++++++++++++++++
claudetool/toolset.go   |  4 ++++
server/convo.go         |  1 +
4 files changed, 50 insertions(+)

Detailed changes

claudetool/bash.go 🔗

@@ -36,6 +36,9 @@ type BashTool struct {
 	WorkingDir *MutableWorkingDir
 	// LLMProvider provides access to LLM services for tool validation
 	LLMProvider LLMServiceProvider
+	// ConversationID is the ID of the conversation this tool belongs to.
+	// It is exposed to invoked commands via SHELLEY_CONVERSATION_ID.
+	ConversationID string
 }
 
 const (
@@ -256,6 +259,9 @@ func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.W
 	})
 	env = append(env, "SKETCH=1")          // signal that this has been run by Sketch, sometimes useful for scripts
 	env = append(env, "EDITOR=/bin/false") // interactive editors won't work
+	if b.ConversationID != "" {
+		env = append(env, "SHELLEY_CONVERSATION_ID="+b.ConversationID)
+	}
 	cmd.Env = env
 	return cmd
 }

claudetool/bash_test.go 🔗

@@ -223,6 +223,45 @@ func TestExecuteBash(t *testing.T) {
 		}
 	})
 
+	// Test SHELLEY_CONVERSATION_ID environment variable is set when configured
+	t.Run("SHELLEY_CONVERSATION_ID Environment Variable", func(t *testing.T) {
+		bashWithConvID := &BashTool{
+			WorkingDir:     NewMutableWorkingDir("/"),
+			ConversationID: "test-conv-123",
+		}
+		req := bashInput{
+			Command: "echo $SHELLEY_CONVERSATION_ID",
+		}
+
+		output, err := bashWithConvID.executeBash(ctx, req, 5*time.Second)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		want := "test-conv-123\n"
+		if output != want {
+			t.Errorf("Expected SHELLEY_CONVERSATION_ID=test-conv-123, got %q", output)
+		}
+	})
+
+	// Test SHELLEY_CONVERSATION_ID is not set when not configured
+	t.Run("SHELLEY_CONVERSATION_ID Not Set When Empty", func(t *testing.T) {
+		req := bashInput{
+			Command: "echo \"conv_id:$SHELLEY_CONVERSATION_ID:\"",
+		}
+
+		output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		// Should be empty since ConversationID is not set on bashTool
+		want := "conv_id::\n"
+		if output != want {
+			t.Errorf("Expected empty SHELLEY_CONVERSATION_ID, got %q", output)
+		}
+	})
+
 	// Test command with output to stderr
 	t.Run("Command with stderr", func(t *testing.T) {
 		req := bashInput{

claudetool/toolset.go 🔗

@@ -57,6 +57,9 @@ type ToolSetConfig struct {
 	SubagentDB SubagentDB
 	// ParentConversationID is the ID of the parent conversation (for subagent tool).
 	ParentConversationID string
+	// ConversationID is the ID of the conversation these tools belong to.
+	// This is exposed to bash commands via the SHELLEY_CONVERSATION_ID environment variable.
+	ConversationID string
 }
 
 // ToolSet holds a set of tools for a single conversation.
@@ -102,6 +105,7 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
 		WorkingDir:       wd,
 		LLMProvider:      cfg.LLMProvider,
 		EnableJITInstall: cfg.EnableJITInstall,
+		ConversationID:   cfg.ConversationID,
 	}
 
 	// Use simplified patch schema for weaker models, full schema for sonnet/opus

server/convo.go 🔗

@@ -363,6 +363,7 @@ func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) e
 	// Create tools for this conversation with the conversation's working directory
 	toolSetConfig.WorkingDir = cwd
 	toolSetConfig.ModelID = modelID
+	toolSetConfig.ConversationID = conversationID
 	toolSetConfig.ParentConversationID = conversationID // For subagent tool
 	toolSetConfig.OnWorkingDirChange = func(newDir string) {
 		// Persist working directory change to database