claudetool: add just-in-time tool installation for bash commands

Implements automatic tool installation when bash commands use missing tools,
providing a seamless experience for the LLM to Just Use tools it knows ought to exist.

Core Features:

1. Command Analysis Pipeline:
   - Parse bash commands to extract individual tools/programs
   - Use exec.LookPath to check if tools exist in PATH
   - Handle shell built-ins, absolute/relative paths correctly
   - Support complex command chaining with &&, ||, ;, and |

2. Subagent Tool Installation:
   - Spawn dedicated subagents to install missing tools

The system preserves existing bash tool behavior while adding invisible
tool installation. Original commands run regardless of installation
success/failure, avoiding agent confusion.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s226cd6260a6469e9k
diff --git a/llm/conversation/convo.go b/llm/conversation/convo.go
index 4740f22..d85515c 100644
--- a/llm/conversation/convo.go
+++ b/llm/conversation/convo.go
@@ -91,7 +91,7 @@
 	// Listener receives messages being sent.
 	Listener Listener
 
-	muToolUseCancel *sync.Mutex
+	toolUseCancelMu sync.Mutex
 	toolUseCancel   map[string]context.CancelCauseFunc
 
 	// Protects usage. This is used for subconversations (that share part of CumulativeUsage) as well.
@@ -118,15 +118,14 @@
 func New(ctx context.Context, srv llm.Service) *Convo {
 	id := newConvoID()
 	return &Convo{
-		Ctx:             skribe.ContextWithAttr(ctx, slog.String("convo_id", id)),
-		Service:         srv,
-		PromptCaching:   true,
-		usage:           newUsage(),
-		Listener:        &NoopListener{},
-		ID:              id,
-		muToolUseCancel: &sync.Mutex{},
-		toolUseCancel:   map[string]context.CancelCauseFunc{},
-		mu:              &sync.Mutex{},
+		Ctx:           skribe.ContextWithAttr(ctx, slog.String("convo_id", id)),
+		Service:       srv,
+		PromptCaching: true,
+		usage:         newUsage(),
+		Listener:      &NoopListener{},
+		ID:            id,
+		toolUseCancel: map[string]context.CancelCauseFunc{},
+		mu:            &sync.Mutex{},
 	}
 }
 
@@ -143,10 +142,11 @@
 		Parent:        c,
 		// For convenience, sub-convo usage shares tool uses map with parent,
 		// all other fields separate, propagated in AddResponse
-		usage:    newUsageWithSharedToolUses(c.usage),
-		mu:       c.mu,
-		Listener: c.Listener,
-		ID:       id,
+		usage:         newUsageWithSharedToolUses(c.usage),
+		mu:            c.mu,
+		Listener:      c.Listener,
+		ID:            id,
+		toolUseCancel: map[string]context.CancelCauseFunc{},
 		// Do not copy Budget. Each budget is independent,
 		// and OverBudget checks whether any ancestor is over budget.
 	}
@@ -200,8 +200,7 @@
 func (c *Convo) messageRequest(msg llm.Message) *llm.Request {
 	system := []llm.SystemContent{}
 	if c.SystemPrompt != "" {
-		var d llm.SystemContent
-		d = llm.SystemContent{Type: "text", Text: c.SystemPrompt}
+		d := llm.SystemContent{Type: "text", Text: c.SystemPrompt}
 		if c.PromptCaching {
 			d.Cache = true
 		}
@@ -381,8 +380,8 @@
 }
 
 func (c *Convo) CancelToolUse(toolUseID string, err error) error {
-	c.muToolUseCancel.Lock()
-	defer c.muToolUseCancel.Unlock()
+	c.toolUseCancelMu.Lock()
+	defer c.toolUseCancelMu.Unlock()
 	cancel, ok := c.toolUseCancel[toolUseID]
 	if !ok {
 		return fmt.Errorf("cannot cancel %s: no cancel function registered for this tool_use_id. All I have is %+v", toolUseID, c.toolUseCancel)
@@ -393,8 +392,8 @@
 }
 
 func (c *Convo) newToolUseContext(ctx context.Context, toolUseID string) (context.Context, context.CancelFunc) {
-	c.muToolUseCancel.Lock()
-	defer c.muToolUseCancel.Unlock()
+	c.toolUseCancelMu.Lock()
+	defer c.toolUseCancelMu.Unlock()
 	ctx, cancel := context.WithCancelCause(ctx)
 	c.toolUseCancel[toolUseID] = cancel
 	return ctx, func() { c.CancelToolUse(toolUseID, nil) }
diff --git a/llm/conversation/convo_test.go b/llm/conversation/convo_test.go
index af8a904..cf4f1c2 100644
--- a/llm/conversation/convo_test.go
+++ b/llm/conversation/convo_test.go
@@ -102,9 +102,9 @@
 					cancelledWithErr = err
 				}
 
-				convo.muToolUseCancel.Lock()
+				convo.toolUseCancelMu.Lock()
 				convo.toolUseCancel[tt.toolUseID] = mockCancel
-				convo.muToolUseCancel.Unlock()
+				convo.toolUseCancelMu.Unlock()
 			}
 
 			err := convo.CancelToolUse(tt.toolUseID, tt.cancelErr)
@@ -126,9 +126,9 @@
 
 			// Verify the toolUseID was removed from the map if it was initially added
 			if tt.setupToolUse {
-				convo.muToolUseCancel.Lock()
+				convo.toolUseCancelMu.Lock()
 				_, exists := convo.toolUseCancel[tt.toolUseID]
-				convo.muToolUseCancel.Unlock()
+				convo.toolUseCancelMu.Unlock()
 
 				if exists {
 					t.Errorf("toolUseID %s still exists in the map after cancellation", tt.toolUseID)