+ TargetID string `json:"target_id,omitempty"`
+ Source string `json:"source"`
+ ID string `json:"id"`
+ Digest string `json:"digest"`
+ Number int `json:"number"`
+}
+
+// notify submits a local event and prints only its acceptance receipt. The
+// deadline and caller cancellation interrupt socket I/O. Losing the answer is
+// an uncertain result, so the caller must retry the same key and text.
+func notify(ctx context.Context, args []string, out io.Writer) (err error) {
+ if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
+ _, err = io.WriteString(out, notifyUsage)
+ if err != nil {
+ return fmt.Errorf("write notification help: %w", err)
+ }
+
+ return nil
+ }
+
+ input, err := notifyParse(args)
+ if err != nil {
+ return fmt.Errorf("parse notification: %w", err)
+ }
+
+ dir, err := dataDir()
+ if err != nil {
+ return fmt.Errorf("find notification data directory: %w", err)
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, notifyTimeout)
+ defer cancel()
+
+ var dialer net.Dialer
+
+ conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName))
+ if err != nil {
+ return fmt.Errorf("connect notification to running daemon: %w", err)
+ }
+
+ defer func() {
+ closeErr := conn.Close()
+ if closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("close notification socket: %w", closeErr))
+ }
+ }()
+
+ interrupted := make(chan error, 1)
+ stop := context.AfterFunc(ctx, func() {
+ err := conn.SetDeadline(time.Now())
+ if err != nil {
+ err = fmt.Errorf("interrupt notification I/O: %w", err)
+ }
+
+ interrupted <- err
+ })
+
+ defer func() {
+ if !stop() {
+ err = errors.Join(err, <-interrupted)
+ }
+ }()
+
+ err = json.NewEncoder(conn).Encode(SocketRequest{
+ Type: "notify",
+ Notify: &input,
+ })
+ if err != nil {
+ return fmt.Errorf("send notification: %w", err)
+ }
+
+ decoder := json.NewDecoder(io.LimitReader(conn, socketRequestBytes))
+ for {
+ var event Event
+
+ err = decoder.Decode(&event)
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ if err != nil {
+ return fmt.Errorf("read notification receipt: %w", err)
+ }
+
+ switch event.Kind {
+ case "notify":
+ if event.ID <= 0 || (event.Status != "accepted" && event.Status != "duplicate") {
+ return errors.New("invalid notification receipt")
+ }
+
+ err = json.NewEncoder(out).Encode(event)
+ if err != nil {
+ return fmt.Errorf("write notification receipt: %w", err)
+ }
+
+ return nil
+ case "error":
+ return fmt.Errorf("notification rejected: %s", event.Text)
+ }
+ }
+}
+
+// notifyParse accepts GNU long flags only and rejects repeated or unknown
+// flags.
+func notifyParse(args []string) (NotifyInput, error) {
+ var input NotifyInput
+
+ fields := map[string]*string{
+ "--agent-id": &input.AgentID,
+ "--source": &input.Source,
+ "--id": &input.ID,
+ "--text": &input.Text,
+ }
+
+ seen := make(map[string]bool)
+ for i := 0; i < len(args); i++ {
+ name, value, equals := strings.Cut(args[i], "=")
+ field := fields[name]
+ if field == nil || seen[name] {
+ return input, errors.New("unknown or repeated notification flag")
+ }
+
+ seen[name] = true
+ if !equals {
+ i++
+ if i == len(args) {
+ return input, errors.New("notification flag needs a value")
+ }
+
+ value = args[i]
+ }
+
+ *field = value
+ }
+
+ if seen["--agent-id"] && input.AgentID == "" {
+ return input, errors.New("notification agent ID must not be empty")
+ }
+
+ err := notifyValidate(input)
+ if err != nil {
+ return input, fmt.Errorf("validate notification: %w", err)
+ }
+
+ return input, nil