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 }