chore: add first hook implementation

Kujtim Hoxha created

Change summary

internal/agent/agent.go                                            | 116 
internal/agent/agent_tool.go                                       |   2 
internal/agent/common_test.go                                      |  15 
internal/agent/coordinator.go                                      |  55 
internal/agent/errors.go                                           |   9 
internal/app/app.go                                                |   1 
internal/db/messages.sql.go                                        |  23 
internal/db/migrations/20251120000000_add_metadata_to_messages.sql |   5 
internal/db/models.go                                              |   1 
internal/db/sql/messages.sql                                       |   4 
internal/hooks/config.go                                           |   8 
internal/hooks/examples_test.go                                    | 136 
internal/hooks/manager.go                                          |  59 
internal/hooks/manager_test.go                                     |  90 
internal/hooks/types.go                                            |  59 
internal/message/content.go                                        |  38 
internal/message/message.go                                        |  16 
17 files changed, 385 insertions(+), 252 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -30,6 +30,7 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/hooks"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
@@ -85,6 +86,9 @@ type sessionAgent struct {
 	messages             message.Service
 	disableAutoSummarize bool
 	isYolo               bool
+	isSubAgent           bool
+	hooksManager         hooks.Manager
+	workingDir           string
 
 	messageQueue   *csync.Map[string, []SessionAgentCall]
 	activeRequests *csync.Map[string, context.CancelFunc]
@@ -97,6 +101,9 @@ type SessionAgentOptions struct {
 	SystemPrompt         string
 	DisableAutoSummarize bool
 	IsYolo               bool
+	IsSubAgent           bool
+	HooksManager         hooks.Manager
+	WorkingDir           string
 	Sessions             session.Service
 	Messages             message.Service
 	Tools                []fantasy.AgentTool
@@ -115,6 +122,9 @@ func NewSessionAgent(
 		disableAutoSummarize: opts.DisableAutoSummarize,
 		tools:                opts.Tools,
 		isYolo:               opts.IsYolo,
+		isSubAgent:           opts.IsSubAgent,
+		hooksManager:         opts.HooksManager,
+		workingDir:           opts.WorkingDir,
 		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
 		activeRequests:       csync.NewMap[string, context.CancelFunc](),
 	}
@@ -172,7 +182,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	}
 
 	// Add the user message to the session.
-	_, err = a.createUserMessage(ctx, call)
+	msg, err := a.createUserMessage(ctx, call)
 	if err != nil {
 		return nil, err
 	}
@@ -186,15 +196,36 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	defer cancel()
 	defer a.activeRequests.Del(call.SessionID)
 
+	// create the agent message asap to show loading
+	var currentAssistant *message.Message
+	assistantMessage, err := a.messages.Create(genCtx, call.SessionID, message.CreateMessageParams{
+		Role:     message.Assistant,
+		Parts:    []message.ContentPart{},
+		Model:    a.largeModel.ModelCfg.Model,
+		Provider: a.largeModel.ModelCfg.Provider,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	currentAssistant = &assistantMessage
+
+	hookErr := a.executePromptSubmitHook(genCtx, &msg, len(msgs) == 0)
+	if hookErr != nil {
+		// Delete the assistant message
+		// use the ctx since this could be a cancellation
+		deleteErr := a.messages.Delete(ctx, currentAssistant.ID)
+		return nil, cmp.Or(deleteErr, hookErr)
+	}
+
 	history, files := a.preparePrompt(msgs, call.Attachments...)
 
 	startTime := time.Now()
 	a.eventPromptSent(call.SessionID)
 
-	var currentAssistant *message.Message
 	var shouldSummarize bool
 	result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
-		Prompt:           call.Prompt,
+		Prompt:           msg.ContentWithHookContext(),
 		Files:            files,
 		Messages:         history,
 		ProviderOptions:  call.ProviderOptions,
@@ -206,6 +237,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 		FrequencyPenalty: call.FrequencyPenalty,
 		// Before each step create a new assistant message.
 		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
+			// only add new assistant message when its not the first step
+			if options.StepNumber != 0 {
+				var assistantMsg message.Message
+				assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
+					Role:     message.Assistant,
+					Parts:    []message.ContentPart{},
+					Model:    a.largeModel.ModelCfg.Model,
+					Provider: a.largeModel.ModelCfg.Provider,
+				})
+				currentAssistant = &assistantMsg
+				// create the message first so we show loading asap
+				if err != nil {
+					return callContext, prepared, err
+				}
+			}
 			prepared.Messages = options.Messages
 			// Reset all cached items.
 			for i := range prepared.Messages {
@@ -219,6 +265,12 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				if createErr != nil {
 					return callContext, prepared, createErr
 				}
+
+				hookErr := a.executePromptSubmitHook(ctx, &msg, len(msgs) == 0)
+				if hookErr != nil {
+					return callContext, prepared, hookErr
+				}
+
 				prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
 			}
 
@@ -242,18 +294,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
 			}
 
-			var assistantMsg message.Message
-			assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
-				Role:     message.Assistant,
-				Parts:    []message.ContentPart{},
-				Model:    a.largeModel.ModelCfg.Model,
-				Provider: a.largeModel.ModelCfg.Provider,
-			})
-			if err != nil {
-				return callContext, prepared, err
-			}
-			callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
-			currentAssistant = &assistantMsg
+			callContext = context.WithValue(callContext, tools.MessageIDContextKey, currentAssistant.ID)
 			return callContext, prepared, err
 		},
 		OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
@@ -882,3 +923,48 @@ func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
 func (a *sessionAgent) Model() Model {
 	return a.largeModel
 }
+
+// executePromptSubmitHook executes the user-prompt-submit hook and applies modifications to the call.
+// Only runs for main agent (not sub-agents).
+func (a *sessionAgent) executePromptSubmitHook(ctx context.Context, msg *message.Message, isFirstMessage bool) error {
+	// Skip if sub-agent or no hooks manager.
+	if a.isSubAgent || a.hooksManager == nil {
+		return nil
+	}
+
+	// Convert attachments to file paths.
+	attachmentPaths := make([]string, len(msg.BinaryContent()))
+	for i, att := range msg.BinaryContent() {
+		attachmentPaths[i] = att.Path
+	}
+
+	hookResult, err := a.hooksManager.ExecuteUserPromptSubmit(ctx, msg.SessionID, a.workingDir, hooks.UserPromptSubmitData{
+		Prompt:         msg.Content().Text,
+		Attachments:    attachmentPaths,
+		Model:          a.largeModel.CatwalkCfg.ID,
+		Provider:       a.largeModel.Model.Provider(),
+		IsFirstMessage: isFirstMessage,
+	})
+	if err != nil {
+		return fmt.Errorf("hook execution failed: %w", err)
+	}
+
+	// Apply hook modifications to the prompt.
+	if hookResult.ModifiedPrompt != nil {
+		for i, part := range msg.Parts {
+			if _, ok := part.(message.TextContent); ok {
+				msg.Parts[i] = message.TextContent{Text: *hookResult.ModifiedPrompt}
+			}
+		}
+	}
+	msg.AddHookResult(hookResult)
+	err = a.messages.Update(ctx, *msg)
+	if err != nil {
+		return err
+	}
+	// If hook returned Continue: false, stop execution.
+	if !hookResult.Continue {
+		return ErrHookExecutionStop
+	}
+	return nil
+}

internal/agent/agent_tool.go 🔗

@@ -34,7 +34,7 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
 		return nil, err
 	}
 
-	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	agent, err := c.buildAgent(ctx, prompt, agentCfg, true)
 	if err != nil {
 		return nil, err
 	}

internal/agent/common_test.go 🔗

@@ -149,7 +149,20 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
 			DefaultMaxTokens: 10000,
 		},
 	}
-	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools})
+	agent := NewSessionAgent(SessionAgentOptions{
+		LargeModel:           largeModel,
+		SmallModel:           smallModel,
+		SystemPromptPrefix:   "",
+		SystemPrompt:         systemPrompt,
+		DisableAutoSummarize: false,
+		IsYolo:               true,
+		IsSubAgent:           false,
+		HooksManager:         nil,
+		WorkingDir:           "",
+		Sessions:             env.sessions,
+		Messages:             env.messages,
+		Tools:                tools,
+	})
 	return agent
 }
 

internal/agent/coordinator.go 🔗

@@ -21,6 +21,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/hooks"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
@@ -55,12 +56,13 @@ type Coordinator interface {
 }
 
 type coordinator struct {
-	cfg         *config.Config
-	sessions    session.Service
-	messages    message.Service
-	permissions permission.Service
-	history     history.Service
-	lspClients  *csync.Map[string, *lsp.Client]
+	cfg          *config.Config
+	sessions     session.Service
+	messages     message.Service
+	permissions  permission.Service
+	history      history.Service
+	lspClients   *csync.Map[string, *lsp.Client]
+	hooksManager hooks.Manager
 
 	currentAgent SessionAgent
 	agents       map[string]SessionAgent
@@ -76,15 +78,17 @@ func NewCoordinator(
 	permissions permission.Service,
 	history history.Service,
 	lspClients *csync.Map[string, *lsp.Client],
+	hooksManager hooks.Manager,
 ) (Coordinator, error) {
 	c := &coordinator{
-		cfg:         cfg,
-		sessions:    sessions,
-		messages:    messages,
-		permissions: permissions,
-		history:     history,
-		lspClients:  lspClients,
-		agents:      make(map[string]SessionAgent),
+		cfg:          cfg,
+		sessions:     sessions,
+		messages:     messages,
+		permissions:  permissions,
+		history:      history,
+		lspClients:   lspClients,
+		hooksManager: hooksManager,
+		agents:       make(map[string]SessionAgent),
 	}
 
 	agentCfg, ok := cfg.Agents[config.AgentCoder]
@@ -98,7 +102,7 @@ func NewCoordinator(
 		return nil, err
 	}
 
-	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
 	if err != nil {
 		return nil, err
 	}
@@ -274,7 +278,7 @@ func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderO
 	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
 }
 
-func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
+func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
 	large, small, err := c.buildAgentModels(ctx)
 	if err != nil {
 		return nil, err
@@ -287,15 +291,18 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
 
 	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
 	result := NewSessionAgent(SessionAgentOptions{
-		large,
-		small,
-		largeProviderCfg.SystemPromptPrefix,
-		systemPrompt,
-		c.cfg.Options.DisableAutoSummarize,
-		c.permissions.SkipRequests(),
-		c.sessions,
-		c.messages,
-		nil,
+		LargeModel:           large,
+		SmallModel:           small,
+		SystemPromptPrefix:   largeProviderCfg.SystemPromptPrefix,
+		SystemPrompt:         systemPrompt,
+		DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
+		IsYolo:               c.permissions.SkipRequests(),
+		IsSubAgent:           isSubAgent,
+		HooksManager:         c.hooksManager,
+		WorkingDir:           c.cfg.WorkingDir(),
+		Sessions:             c.sessions,
+		Messages:             c.messages,
+		Tools:                nil,
 	})
 	c.readyWg.Go(func() error {
 		tools, err := c.buildTools(ctx, agent)

internal/agent/errors.go 🔗

@@ -6,10 +6,11 @@ import (
 )
 
 var (
-	ErrRequestCancelled = errors.New("request canceled by user")
-	ErrSessionBusy      = errors.New("session is currently processing another request")
-	ErrEmptyPrompt      = errors.New("prompt is empty")
-	ErrSessionMissing   = errors.New("session id is missing")
+	ErrRequestCancelled  = errors.New("request canceled by user")
+	ErrSessionBusy       = errors.New("session is currently processing another request")
+	ErrEmptyPrompt       = errors.New("prompt is empty")
+	ErrSessionMissing    = errors.New("session id is missing")
+	ErrHookExecutionStop = errors.New("hook stopped execution")
 )
 
 func isCancelledErr(err error) bool {

internal/app/app.go 🔗

@@ -333,6 +333,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		app.Permissions,
 		app.History,
 		app.LSPClients,
+		app.HooksManager,
 	)
 	if err != nil {
 		slog.Error("Failed to create coder agent", "err", err)

internal/db/messages.sql.go 🔗

@@ -19,12 +19,13 @@ INSERT INTO messages (
     model,
     provider,
     is_summary_message,
+    metadata,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
-RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message, metadata
 `
 
 type CreateMessageParams struct {
@@ -35,6 +36,7 @@ type CreateMessageParams struct {
 	Model            sql.NullString `json:"model"`
 	Provider         sql.NullString `json:"provider"`
 	IsSummaryMessage int64          `json:"is_summary_message"`
+	Metadata         string         `json:"metadata"`
 }
 
 func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -46,6 +48,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		arg.Model,
 		arg.Provider,
 		arg.IsSummaryMessage,
+		arg.Metadata,
 	)
 	var i Message
 	err := row.Scan(
@@ -59,6 +62,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		&i.FinishedAt,
 		&i.Provider,
 		&i.IsSummaryMessage,
+		&i.Metadata,
 	)
 	return i, err
 }
@@ -84,7 +88,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
 }
 
 const getMessage = `-- name: GetMessage :one
-SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message, metadata
 FROM messages
 WHERE id = ? LIMIT 1
 `
@@ -103,12 +107,13 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
 		&i.FinishedAt,
 		&i.Provider,
 		&i.IsSummaryMessage,
+		&i.Metadata,
 	)
 	return i, err
 }
 
 const listMessagesBySession = `-- name: ListMessagesBySession :many
-SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message, metadata
 FROM messages
 WHERE session_id = ?
 ORDER BY created_at ASC
@@ -134,6 +139,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
 			&i.FinishedAt,
 			&i.Provider,
 			&i.IsSummaryMessage,
+			&i.Metadata,
 		); err != nil {
 			return nil, err
 		}
@@ -152,6 +158,7 @@ const updateMessage = `-- name: UpdateMessage :exec
 UPDATE messages
 SET
     parts = ?,
+    metadata = ?,
     finished_at = ?,
     updated_at = strftime('%s', 'now')
 WHERE id = ?
@@ -159,11 +166,17 @@ WHERE id = ?
 
 type UpdateMessageParams struct {
 	Parts      string        `json:"parts"`
+	Metadata   string        `json:"metadata"`
 	FinishedAt sql.NullInt64 `json:"finished_at"`
 	ID         string        `json:"id"`
 }
 
 func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
-	_, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.FinishedAt, arg.ID)
+	_, err := q.exec(ctx, q.updateMessageStmt, updateMessage,
+		arg.Parts,
+		arg.Metadata,
+		arg.FinishedAt,
+		arg.ID,
+	)
 	return err
 }

internal/db/models.go 🔗

@@ -29,6 +29,7 @@ type Message struct {
 	FinishedAt       sql.NullInt64  `json:"finished_at"`
 	Provider         sql.NullString `json:"provider"`
 	IsSummaryMessage int64          `json:"is_summary_message"`
+	Metadata         string         `json:"metadata"`
 }
 
 type Session struct {

internal/db/sql/messages.sql 🔗

@@ -18,10 +18,11 @@ INSERT INTO messages (
     model,
     provider,
     is_summary_message,
+    metadata,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
 RETURNING *;
 
@@ -29,6 +30,7 @@ RETURNING *;
 UPDATE messages
 SET
     parts = ?,
+    metadata = ?,
     finished_at = ?,
     updated_at = strftime('%s', 'now')
 WHERE id = ?;

internal/hooks/config.go 🔗

@@ -2,8 +2,8 @@ package hooks
 
 // Config defines hook system configuration.
 type Config struct {
-	// Enabled controls whether hooks are executed.
-	Enabled bool `json:"enabled,omitempty" jsonschema:"description=Enable or disable hook execution,default=true"`
+	// Disabled controls whether hooks are executed.
+	Disabled bool `json:"disabled,omitempty" jsonschema:"description=Disable hook execution,default=false"`
 
 	// TimeoutSeconds is the maximum time a hook can run.
 	TimeoutSeconds int `json:"timeout_seconds,omitempty" jsonschema:"description=Maximum execution time for hooks in seconds,default=30,example=30"`
@@ -16,10 +16,10 @@ type Config struct {
 	// Map key is the hook type (e.g., "pre-tool-use").
 	Inline map[string][]InlineHook `json:"inline,omitempty" jsonschema:"description=Inline hook scripts defined in configuration"`
 
-	// Disabled is a list of hook paths to skip.
+	// DisableHooks is a list of hook paths to skip.
 	// Paths are relative to the hooks directory.
 	// Example: ["pre-tool-use/02-slow-check.sh"]
-	Disabled []string `json:"disabled,omitempty" jsonschema:"description=List of hook paths to disable,example=pre-tool-use/02-slow-check.sh"`
+	DisableHooks []string `json:"disable_hooks,omitempty" jsonschema:"description=List of hook paths to disable,example=pre-tool-use/02-slow-check.sh"`
 
 	// Environment variables to pass to hooks.
 	Environment map[string]string `json:"environment,omitempty" jsonschema:"description=Environment variables to pass to all hooks"`

internal/hooks/examples_test.go 🔗

@@ -35,15 +35,11 @@ fi
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
 		// Test: Should block "rm -rf /"
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 			ToolName:   "bash",
 			ToolCallID: "call-1",
-			Data: map[string]any{
-				"tool_input": map[string]any{
-					"command": "rm -rf /",
-				},
+			ToolInput: map[string]any{
+				"command": "rm -rf /",
 			},
 		})
 
@@ -53,15 +49,11 @@ fi
 		assert.Contains(t, result.Message, "Blocked dangerous command")
 
 		// Test: Should allow safe commands
-		result2, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
+		result2, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 			ToolName:   "bash",
 			ToolCallID: "call-2",
-			Data: map[string]any{
-				"tool_input": map[string]any{
-					"command": "ls -la",
-				},
+			ToolInput: map[string]any{
+				"command": "ls -la",
 			},
 		})
 
@@ -94,12 +86,9 @@ esac
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
 		// Test: Should auto-approve view tool
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 			ToolName:   "view",
 			ToolCallID: "call-1",
-			Data:       map[string]any{},
 		})
 
 		require.NoError(t, err)
@@ -108,15 +97,11 @@ esac
 		assert.Contains(t, result.Message, "Auto-approved read-only tool")
 
 		// Test: Should auto-approve safe bash commands
-		result2, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
+		result2, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 			ToolName:   "bash",
 			ToolCallID: "call-2",
-			Data: map[string]any{
-				"tool_input": map[string]any{
-					"command": "ls -la",
-				},
+			ToolInput: map[string]any{
+				"command": "ls -la",
 			},
 		})
 
@@ -156,12 +141,8 @@ fi
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data: map[string]any{
-				"prompt": "help me",
-			},
+		result, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
+			Prompt: "help me",
 		})
 
 		require.NoError(t, err)
@@ -188,12 +169,9 @@ echo "$TIMESTAMP|$CRUSH_TOOL_NAME|$CRUSH_TOOL_CALL_ID" >> "$AUDIT_FILE"
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPostToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
+		result, err := manager.ExecutePostToolUse(context.Background(), "test", tempDir, PostToolUseData{
 			ToolName:   "bash",
 			ToolCallID: "call-123",
-			Data:       map[string]any{},
 		})
 
 		require.NoError(t, err)
@@ -221,18 +199,10 @@ echo "Hook: $CRUSH_HOOK_TYPE" >> "` + logFile + `"
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
 		// Test with different hook types
-		_, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		_, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
 		require.NoError(t, err)
 
-		_, err = manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		_, err = manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{})
 		require.NoError(t, err)
 
 		// Verify both hook types were logged
@@ -271,11 +241,7 @@ fi
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.False(t, result.Continue, "Should stop execution when rate limit exceeded")
@@ -302,11 +268,7 @@ fi
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{})
 
 		require.NoError(t, err)
 		assert.True(t, result.Continue)
@@ -330,15 +292,11 @@ echo "{\"modified_input\": {\"command\": \"$SAFE_CMD\"}}"
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 			ToolName:   "bash",
 			ToolCallID: "call-1",
-			Data: map[string]any{
-				"tool_input": map[string]any{
-					"command": "rm --force file.txt",
-				},
+			ToolInput: map[string]any{
+				"command": "rm --force file.txt",
 			},
 		})
 
@@ -363,11 +321,7 @@ export CRUSH_MESSAGE="Auto-approved"
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.True(t, result.Continue)
@@ -403,11 +357,7 @@ fi
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.False(t, result.Continue, "Exit code 2 should stop execution")
@@ -442,13 +392,9 @@ export CRUSH_MODIFIED_PROMPT="Enhanced: $PROMPT"
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data: map[string]any{
-				"prompt": "original prompt",
-				"model":  "gpt-4",
-			},
+		result, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
+			Prompt: "original prompt",
+			Model:  "gpt-4",
 		})
 
 		require.NoError(t, err)
@@ -480,25 +426,17 @@ fi
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
 		// Test: First message
-		result1, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data: map[string]any{
-				"prompt":           "first prompt",
-				"is_first_message": true,
-			},
+		result1, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
+			Prompt:         "first prompt",
+			IsFirstMessage: true,
 		})
 		require.NoError(t, err)
 		assert.Contains(t, result1.ContextContent, "This is the first message")
 
 		// Test: Follow-up message
-		result2, err := manager.ExecuteHooks(context.Background(), HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data: map[string]any{
-				"prompt":           "follow-up prompt",
-				"is_first_message": false,
-			},
+		result2, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
+			Prompt:         "follow-up prompt",
+			IsFirstMessage: false,
 		})
 		require.NoError(t, err)
 		assert.Contains(t, result2.ContextContent, "This is a follow-up message")
@@ -532,11 +470,7 @@ export CRUSH_MESSAGE="${CRUSH_MESSAGE:-}; third"
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		// Messages should be merged in order
@@ -563,11 +497,7 @@ echo '{"message": "Combined output", "modified_input": {"key": "value"}}'
 
 		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 
-		result, err := manager.ExecuteHooks(context.Background(), HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.True(t, result.Continue)

internal/hooks/manager.go 🔗

@@ -25,10 +25,9 @@ type manager struct {
 }
 
 // NewManager creates a new hook manager.
-func NewManager(workingDir, dataDir string, cfg *Config) Manager {
+func NewManager(workingDir, dataDir string, cfg *Config) *manager {
 	if cfg == nil {
 		cfg = &Config{
-			Enabled:        true,
 			TimeoutSeconds: 30,
 			Directories:    []string{filepath.Join(dataDir, "hooks")},
 		}
@@ -63,9 +62,9 @@ func isExecutable(info os.FileInfo) bool {
 	return info.Mode()&0o111 != 0
 }
 
-// ExecuteHooks implements Manager.
-func (m *manager) ExecuteHooks(ctx context.Context, hookType HookType, hookContext HookContext) (HookResult, error) {
-	if !m.config.Enabled {
+// executeHooks is the internal method that executes hooks for a given type.
+func (m *manager) executeHooks(ctx context.Context, hookType HookType, hookContext HookContext) (HookResult, error) {
+	if m.config.Disabled {
 		return HookResult{Continue: true}, nil
 	}
 
@@ -220,7 +219,7 @@ func (m *manager) isDisabled(hookPath string) bool {
 		if rel, err := filepath.Rel(dir, hookPath); err == nil {
 			// Normalize to forward slashes for cross-platform comparison
 			rel = filepath.ToSlash(rel)
-			if slices.Contains(m.config.Disabled, rel) {
+			if slices.Contains(m.config.DisableHooks, rel) {
 				return true
 			}
 		}
@@ -283,3 +282,51 @@ func (m *manager) mergeResults(accumulated *HookResult, new *HookResult) {
 func (m *manager) ListHooks(hookType HookType) []string {
 	return m.discoverHooks(hookType)
 }
+
+// ExecuteUserPromptSubmit executes user-prompt-submit hooks.
+func (m *manager) ExecuteUserPromptSubmit(ctx context.Context, sessionID, workingDir string, data UserPromptSubmitData) (HookResult, error) {
+	hookCtx := HookContext{
+		SessionID:  sessionID,
+		WorkingDir: workingDir,
+		Data:       data,
+	}
+
+	return m.executeHooks(ctx, HookUserPromptSubmit, hookCtx)
+}
+
+// ExecutePreToolUse executes pre-tool-use hooks.
+func (m *manager) ExecutePreToolUse(ctx context.Context, sessionID, workingDir string, data PreToolUseData) (HookResult, error) {
+	hookCtx := HookContext{
+		SessionID:  sessionID,
+		WorkingDir: workingDir,
+		ToolName:   data.ToolName,
+		ToolCallID: data.ToolCallID,
+		Data:       data,
+	}
+
+	return m.executeHooks(ctx, HookPreToolUse, hookCtx)
+}
+
+// ExecutePostToolUse executes post-tool-use hooks.
+func (m *manager) ExecutePostToolUse(ctx context.Context, sessionID, workingDir string, data PostToolUseData) (HookResult, error) {
+	hookCtx := HookContext{
+		SessionID:  sessionID,
+		WorkingDir: workingDir,
+		ToolName:   data.ToolName,
+		ToolCallID: data.ToolCallID,
+		Data:       data,
+	}
+
+	return m.executeHooks(ctx, HookPostToolUse, hookCtx)
+}
+
+// ExecuteStop executes stop hooks.
+func (m *manager) ExecuteStop(ctx context.Context, sessionID, workingDir string, data StopData) (HookResult, error) {
+	hookCtx := HookContext{
+		SessionID:  sessionID,
+		WorkingDir: workingDir,
+		Data:       data,
+	}
+
+	return m.executeHooks(ctx, HookStop, hookCtx)
+}

internal/hooks/manager_test.go 🔗

@@ -109,12 +109,8 @@ crush_add_context "Context from hook 2"
 
 		mgr := NewManager(tempDir, dataDir, nil)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data: map[string]any{
-				"prompt": "test prompt",
-			},
+		result, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{
+			Prompt: "test prompt",
 		})
 
 		require.NoError(t, err)
@@ -145,11 +141,7 @@ export CRUSH_MESSAGE="should not see this"
 
 		mgr := NewManager(tempDir, dataDir, nil)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.False(t, result.Continue)
@@ -180,11 +172,7 @@ export CRUSH_PERMISSION=deny
 
 		mgr := NewManager(tempDir, dataDir, nil)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.Equal(t, "deny", result.Permission)
@@ -211,19 +199,14 @@ export CRUSH_MESSAGE="disabled"
 		require.NoError(t, err)
 
 		cfg := &Config{
-			Enabled:        true,
 			TimeoutSeconds: 30,
 			Directories:    []string{filepath.Join(dataDir, "hooks")},
-			Disabled:       []string{"pre-tool-use/02-disabled.sh"},
+			DisableHooks:   []string{"pre-tool-use/02-disabled.sh"},
 		}
 
 		mgr := NewManager(tempDir, dataDir, cfg)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.Equal(t, "enabled", result.Message)
@@ -234,7 +217,6 @@ export CRUSH_MESSAGE="disabled"
 		dataDir := filepath.Join(tempDir, ".crush")
 
 		cfg := &Config{
-			Enabled:        true,
 			TimeoutSeconds: 30,
 			Directories:    []string{filepath.Join(dataDir, "hooks")},
 			Inline: map[string][]InlineHook{
@@ -251,11 +233,7 @@ export CRUSH_MESSAGE="inline hook executed"
 
 		mgr := NewManager(tempDir, dataDir, cfg)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{})
 
 		require.NoError(t, err)
 		assert.Equal(t, "inline hook executed", result.Message)
@@ -265,17 +243,11 @@ export CRUSH_MESSAGE="inline hook executed"
 		tempDir := t.TempDir()
 		dataDir := filepath.Join(tempDir, ".crush")
 
-		cfg := &Config{
-			Enabled: false,
-		}
+		cfg := &Config{}
 
 		mgr := NewManager(tempDir, dataDir, cfg)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.True(t, result.Continue)
@@ -288,11 +260,7 @@ export CRUSH_MESSAGE="inline hook executed"
 
 		mgr := NewManager(tempDir, dataDir, nil)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.True(t, result.Continue)
@@ -313,11 +281,7 @@ exit 1
 
 		mgr := NewManager(tempDir, dataDir, nil)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.False(t, result.Continue)
@@ -372,22 +336,14 @@ export CRUSH_MESSAGE="$CRUSH_MESSAGE; specific hook"
 
 		// Test PreToolUse - should execute both catch-all and specific.
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.Contains(t, result.Message, "catch-all: pre-tool-use")
 		assert.Contains(t, result.Message, "specific hook")
 
 		// Test UserPromptSubmit - should only execute catch-all.
-		result2, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result2, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{})
 
 		require.NoError(t, err)
 		assert.Equal(t, "catch-all: user-prompt-submit", result2.Message)
@@ -412,7 +368,6 @@ fi
 		require.NoError(t, err)
 
 		cfg := &Config{
-			Enabled:        true,
 			TimeoutSeconds: 30,
 			Directories:    []string{filepath.Join(dataDir, "hooks")},
 			Environment: map[string]string{
@@ -423,11 +378,7 @@ fi
 
 		mgr := NewManager(tempDir, dataDir, cfg)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.Equal(t, "config environment variables received", result.Message)
@@ -440,7 +391,6 @@ fi
 		require.NoError(t, os.MkdirAll(readOnlyDir, 0o555)) // Read-only
 
 		cfg := &Config{
-			Enabled:        true,
 			TimeoutSeconds: 30,
 			Directories:    []string{filepath.Join(readOnlyDir, "hooks")},
 			Inline: map[string][]InlineHook{
@@ -458,11 +408,7 @@ fi
 
 		// Should not error even though inline hook write fails.
 		// The hook will be skipped and logged.
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.True(t, result.Continue) // Should continue despite write failure
@@ -511,11 +457,7 @@ export CRUSH_MESSAGE="auto-approved"
 
 		mgr := NewManager(tempDir, dataDir, nil)
 		ctx := context.Background()
-		result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
-			SessionID:  "test",
-			WorkingDir: tempDir,
-			Data:       map[string]any{},
-		})
+		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
 
 		require.NoError(t, err)
 		assert.Equal(t, "approve", result.Permission)

internal/hooks/types.go 🔗

@@ -40,7 +40,7 @@ type HookContext struct {
 	// For PreToolUse: tool_name, tool_call_id, tool_input
 	// For PostToolUse: tool_name, tool_call_id, tool_input, tool_output, execution_time_ms
 	// For Stop: reason
-	Data map[string]any
+	Data any
 
 	// ToolName is the tool name (for tool hooks only).
 	ToolName string
@@ -54,40 +54,73 @@ type HookContext struct {
 
 // HookResult contains the result of hook execution.
 type HookResult struct {
+	// HookType the hook type
+	HookType HookType `json:"hook_type"`
+	// Name the name of the hook (usually the file name)
+	Name string `json:"name"`
+	// Path hook path
+	Path string `json:"path"`
+	// AllResults stores all results for this event
+	AllResults []HookResult `json:"all_results,omitempty"`
 	// Continue indicates whether to continue execution.
 	// If false, execution stops.
-	Continue bool
+	Continue bool `json:"continue"`
 
 	// Permission decision (for PreToolUse hooks only).
 	// Values: "ask" (default), "approve", "deny"
-	Permission string
+	Permission string `json:"permission"`
 
 	// ModifiedPrompt is the modified user prompt (for UserPromptSubmit).
-	ModifiedPrompt *string
+	ModifiedPrompt *string `json:"modified_prompt"`
 
 	// ModifiedInput is the modified tool input parameters (for PreToolUse).
 	// This is a map that can be merged with the original tool input.
-	ModifiedInput map[string]any
+	ModifiedInput map[string]any `json:"modified_input"`
 
 	// ModifiedOutput is the modified tool output (for PostToolUse).
-	ModifiedOutput map[string]any
+	ModifiedOutput map[string]any `json:"modified_output"`
 
 	// ContextContent is raw text content to add to LLM context.
-	ContextContent string
+	ContextContent string `json:"context_content"`
 
 	// ContextFiles is a list of file paths to load and add to LLM context.
-	ContextFiles []string
+	ContextFiles []string `json:"context_files"`
 
 	// Message is a user-facing message (logged and potentially displayed).
-	Message string
+	Message string `json:"message"`
 }
 
 // Manager coordinates hook discovery and execution.
 type Manager interface {
-	// ExecuteHooks executes all hooks for the given type in order.
-	// Returns accumulated results from all hooks.
-	ExecuteHooks(ctx context.Context, hookType HookType, context HookContext) (HookResult, error)
-
 	// ListHooks returns all discovered hooks for a given type.
 	ListHooks(hookType HookType) []string
+
+	// ExecuteUserPromptSubmit executes the UserPromptSubmit event
+	ExecuteUserPromptSubmit(ctx context.Context, sessionID, workingDir string, data UserPromptSubmitData) (HookResult, error)
+}
+
+type UserPromptSubmitData struct {
+	Prompt         string   `json:"prompt"`
+	Attachments    []string `json:"attachments"`
+	Model          string   `json:"model"`
+	Provider       string   `json:"provider"`
+	IsFirstMessage bool     `json:"is_first_message"`
+}
+
+type PreToolUseData struct {
+	ToolName   string         `json:"tool_name"`
+	ToolCallID string         `json:"tool_call_id"`
+	ToolInput  map[string]any `json:"tool_input"`
+}
+
+type PostToolUseData struct {
+	ToolName        string         `json:"tool_name"`
+	ToolCallID      string         `json:"tool_call_id"`
+	ToolInput       map[string]any `json:"tool_input"`
+	ToolOutput      map[string]any `json:"tool_output"`
+	ExecutionTimeMs int64          `json:"execution_time_ms"`
+}
+
+type StopData struct {
+	Reason string `json:"reason"`
 }

internal/message/content.go 🔗

@@ -3,6 +3,7 @@ package message
 import (
 	"encoding/base64"
 	"errors"
+	"fmt"
 	"slices"
 	"strings"
 	"time"
@@ -12,6 +13,7 @@ import (
 	"charm.land/fantasy/providers/google"
 	"charm.land/fantasy/providers/openai"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/hooks"
 )
 
 type MessageRole string
@@ -134,6 +136,11 @@ type Message struct {
 	CreatedAt        int64
 	UpdatedAt        int64
 	IsSummaryMessage bool
+	Metadata         MessageMetadata
+}
+
+type MessageMetadata struct {
+	Hooks *hooks.HookResult `json:"hooks,omitempty"`
 }
 
 func (m *Message) Content() TextContent {
@@ -145,6 +152,30 @@ func (m *Message) Content() TextContent {
 	return TextContent{}
 }
 
+func (m *Message) ContentWithHookContext() string {
+	text := strings.TrimSpace(m.Content().Text)
+	if m.Metadata.Hooks == nil {
+		return text
+	}
+
+	hookContext := strings.TrimSpace(m.Metadata.Hooks.ContextContent)
+	if hookContext != "" {
+		text += fmt.Sprintf("## Additional Context\n %s", hookContext)
+	}
+
+	if len(m.Metadata.Hooks.ContextFiles) > 0 {
+		text += "\n## Additional Context Files\n"
+
+		for _, file := range m.Metadata.Hooks.ContextFiles {
+			text += fmt.Sprintf("- %s\n", file)
+		}
+
+		text += "**Note: Read these files if needed**"
+	}
+
+	return text
+}
+
 func (m *Message) ReasoningContent() ReasoningContent {
 	for _, part := range m.Parts {
 		if c, ok := part.(ReasoningContent); ok {
@@ -241,6 +272,11 @@ func (m *Message) AppendContent(delta string) {
 	}
 }
 
+// AddHookResult adds the result of the hooks for this message
+func (m *Message) AddHookResult(hooks hooks.HookResult) {
+	m.Metadata.Hooks = &hooks
+}
+
 func (m *Message) AppendReasoningContent(delta string) {
 	found := false
 	for i, part := range m.Parts {
@@ -431,7 +467,7 @@ func (m *Message) ToAIMessage() []fantasy.Message {
 	switch m.Role {
 	case User:
 		var parts []fantasy.MessagePart
-		text := strings.TrimSpace(m.Content().Text)
+		text := strings.TrimSpace(m.ContentWithHookContext())
 		if text != "" {
 			parts = append(parts, fantasy.TextPart{Text: text})
 		}

internal/message/message.go 🔗

@@ -18,6 +18,7 @@ type CreateMessageParams struct {
 	Model            string
 	Provider         string
 	IsSummaryMessage bool
+	Metadata         MessageMetadata
 }
 
 type Service interface {
@@ -65,6 +66,10 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
 	if err != nil {
 		return Message{}, err
 	}
+	metadataJSON, err := json.Marshal(params.Metadata)
+	if err != nil {
+		return Message{}, err
+	}
 	isSummary := int64(0)
 	if params.IsSummaryMessage {
 		isSummary = 1
@@ -77,6 +82,7 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
 		Model:            sql.NullString{String: string(params.Model), Valid: true},
 		Provider:         sql.NullString{String: params.Provider, Valid: params.Provider != ""},
 		IsSummaryMessage: isSummary,
+		Metadata:         string(metadataJSON),
 	})
 	if err != nil {
 		return Message{}, err
@@ -110,6 +116,10 @@ func (s *service) Update(ctx context.Context, message Message) error {
 	if err != nil {
 		return err
 	}
+	metadata, err := json.Marshal(message.Metadata)
+	if err != nil {
+		return err
+	}
 	finishedAt := sql.NullInt64{}
 	if f := message.FinishPart(); f != nil {
 		finishedAt.Int64 = f.Time
@@ -118,6 +128,7 @@ func (s *service) Update(ctx context.Context, message Message) error {
 	err = s.q.UpdateMessage(ctx, db.UpdateMessageParams{
 		ID:         message.ID,
 		Parts:      string(parts),
+		Metadata:   string(metadata),
 		FinishedAt: finishedAt,
 	})
 	if err != nil {
@@ -156,6 +167,10 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
 	if err != nil {
 		return Message{}, err
 	}
+	var metadata MessageMetadata
+	if err := json.Unmarshal([]byte(item.Metadata), &metadata); err != nil {
+		return Message{}, err
+	}
 	return Message{
 		ID:               item.ID,
 		SessionID:        item.SessionID,
@@ -166,6 +181,7 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
 		CreatedAt:        item.CreatedAt,
 		UpdatedAt:        item.UpdatedAt,
 		IsSummaryMessage: item.IsSummaryMessage != 0,
+		Metadata:         metadata,
 	}, nil
 }