/ internal / tools / notify.go
notify.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"fmt"
  7  	"os/exec"
  8  
  9  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 10  )
 11  
 12  // NotifyHandler delivers a notify tool call through an attached daemon client
 13  // (typically the Desktop app) instead of shelling out to osascript. It returns
 14  // true when the notification was delivered — in which case the tool skips the
 15  // osascript fallback — and false when no client is attached, which tells the
 16  // tool to fall back to osascript for headless mode.
 17  type NotifyHandler func(title, body string, sound bool) bool
 18  
 19  type notifyHandlerKey struct{}
 20  
 21  // WithNotifyHandler returns a context carrying a NotifyHandler. The daemon
 22  // runner attaches one per run so that notify tool calls from scheduled or
 23  // interactive agents can be routed through the Desktop's UNUserNotificationCenter
 24  // with correct app attribution and click-through.
 25  func WithNotifyHandler(ctx context.Context, h NotifyHandler) context.Context {
 26  	if h == nil {
 27  		return ctx
 28  	}
 29  	return context.WithValue(ctx, notifyHandlerKey{}, h)
 30  }
 31  
 32  // NotifyHandlerFrom returns the NotifyHandler from ctx, or nil if none is set.
 33  func NotifyHandlerFrom(ctx context.Context) NotifyHandler {
 34  	h, _ := ctx.Value(notifyHandlerKey{}).(NotifyHandler)
 35  	return h
 36  }
 37  
 38  type NotifyTool struct{}
 39  
 40  type notifyArgs struct {
 41  	Title   string `json:"title"`
 42  	Body    string `json:"body,omitempty"`
 43  	Message string `json:"message,omitempty"` // alias for body
 44  	Sound   bool   `json:"sound,omitempty"`
 45  }
 46  
 47  func (t *NotifyTool) Info() agent.ToolInfo {
 48  	return agent.ToolInfo{
 49  		Name:        "notify",
 50  		Description: "Send a macOS desktop notification using osascript.",
 51  		Parameters: map[string]any{
 52  			"type": "object",
 53  			"properties": map[string]any{
 54  				"title": map[string]any{"type": "string", "description": "Notification title"},
 55  				"body":    map[string]any{"type": "string", "description": "Notification body text (alias: message)"},
 56  			"message": map[string]any{"type": "string", "description": "Alias for body"},
 57  				"sound": map[string]any{"type": "boolean", "description": "Play notification sound (default: false)"},
 58  			},
 59  		},
 60  		Required: []string{"title"},
 61  	}
 62  }
 63  
 64  func (t *NotifyTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
 65  	var args notifyArgs
 66  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
 67  		return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil
 68  	}
 69  
 70  	body := args.Body
 71  	if body == "" {
 72  		body = args.Message
 73  	}
 74  
 75  	// Prefer the Desktop route when a handler is attached. The handler returns
 76  	// true when a Desktop client is actually subscribed; if it returns false,
 77  	// the daemon is headless and we fall through to the osascript path so the
 78  	// banner still shows (attributed to Script Editor, which is expected in
 79  	// headless mode since there's no app bundle to attribute it to).
 80  	if h := NotifyHandlerFrom(ctx); h != nil {
 81  		if h(args.Title, body, args.Sound) {
 82  			return agent.ToolResult{Content: "notification sent"}, nil
 83  		}
 84  	}
 85  
 86  	script := buildNotifyScript(args.Title, body, args.Sound)
 87  
 88  	cmd := exec.CommandContext(ctx, "osascript", "-e", script)
 89  	output, err := cmd.CombinedOutput()
 90  	if err != nil {
 91  		return agent.ToolResult{Content: fmt.Sprintf("notification error: %v\n%s", err, string(output)), IsError: true}, nil
 92  	}
 93  
 94  	return agent.ToolResult{Content: "notification sent"}, nil
 95  }
 96  
 97  func buildNotifyScript(title, body string, sound bool) string {
 98  	title = escapeAppleScript(title)
 99  	body = escapeAppleScript(body)
100  
101  	script := fmt.Sprintf(`display notification "%s" with title "%s"`, body, title)
102  	if sound {
103  		script += ` sound name "default"`
104  	}
105  	return script
106  }
107  
108  func (t *NotifyTool) RequiresApproval() bool { return true }
109  
110  func (t *NotifyTool) IsReadOnlyCall(string) bool { return false }