hermes-notify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ """Hermes notification plugin — rule matching, audio playback, display control.
2
+
3
+ Transport-agnostic.
4
+ Reads messages from stdin JSON or Bus hook, matches against notify.yaml rules.
5
+
6
+ Multi-backend architecture:
7
+ stdin / Bus hook -> bus_callback.py -> notify.yaml rules -> execute callback
8
+
9
+ Current backends:
10
+ - bus (subprocess hook mode)
11
+ Backends to add:
12
+ - tmux, stdout, webhook
13
+ """
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """Bus message callback router.
3
+
4
+ Spawned by Bus Server as post-route hook.
5
+ Reads message JSON from stdin, matches callbacks config rules, executes callbacks.
6
+ """
7
+ import json
8
+ import logging
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from typing import Optional
14
+
15
+ HERMES_HOME = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
16
+ _notify_dir = os.path.join(HERMES_HOME, "hermes-notify")
17
+ if os.path.isdir(_notify_dir):
18
+ sys.path.insert(0, _notify_dir)
19
+
20
+ CONFIG_PATH = os.path.join(HERMES_HOME, "hermes-notify", "notify.yaml")
21
+
22
+ logging.basicConfig(
23
+ level=logging.WARNING,
24
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
25
+ datefmt="%Y-%m-%dT%H:%M:%S",
26
+ filename=os.path.join(HERMES_HOME, "logs", "errors.log"),
27
+ filemode="a",
28
+ )
29
+ log = logging.getLogger("bus.callback")
30
+
31
+ # Consecutive failure count: rule_key -> (fail_count, last_fail_time, suppressed)
32
+ _failure_tracker: dict[str, list] = {}
33
+ FAILURE_THRESHOLD = 3
34
+ FAILURE_SUPPRESS_SECONDS = 300 # suppress for 5 minutes after threshold
35
+
36
+
37
+ def load_yaml_config(path: str) -> dict:
38
+ """Load YAML config file (no third-party deps)."""
39
+ if not os.path.exists(path):
40
+ log.warning(f"Config file not found: {path}")
41
+ return {"callbacks": []}
42
+
43
+ with open(path) as f:
44
+ content = f.read()
45
+
46
+ config = {"callbacks": []}
47
+ current_rule = None
48
+
49
+ for line in content.split("\n"):
50
+ stripped = line.strip()
51
+ if not stripped or stripped.startswith("#"):
52
+ continue
53
+
54
+ indent = len(line) - len(line.lstrip())
55
+
56
+ if any(stripped.startswith(f"- {p}:") for p in
57
+ ["match_type", "command"]):
58
+ current_rule = {}
59
+ config["callbacks"].append(current_rule)
60
+ key, _, val = stripped.lstrip("- ").partition(":")
61
+ current_rule[key.strip()] = val.strip().strip("'\"")
62
+
63
+ elif current_rule is not None and indent >= 4:
64
+ key, _, val = stripped.partition(":")
65
+ key = key.strip()
66
+ val = val.strip().strip("'\"")
67
+
68
+ if key == "priority":
69
+ current_rule[key] = int(val)
70
+ elif key in ("print", "context"):
71
+ current_rule[key] = val.lower() in ("true", "yes", "1")
72
+ elif key == "command":
73
+ current_rule[key] = os.path.expanduser(val)
74
+ elif key == "match_type":
75
+ current_rule[key] = val
76
+ else:
77
+ current_rule[key] = val
78
+
79
+ return config
80
+
81
+
82
+ def should_suppress(rule_key: str) -> bool:
83
+ """Check if this rule should be suppressed (too many consecutive failures)."""
84
+ if rule_key not in _failure_tracker:
85
+ return False
86
+ count, last_fail, suppressed = _failure_tracker[rule_key]
87
+ if suppressed and time.time() - last_fail < FAILURE_SUPPRESS_SECONDS:
88
+ return True
89
+ if suppressed and time.time() - last_fail >= FAILURE_SUPPRESS_SECONDS:
90
+ # Unsuppress
91
+ del _failure_tracker[rule_key]
92
+ return False
93
+ return False
94
+
95
+
96
+ def record_failure(rule_key: str):
97
+ """Record one failure."""
98
+ if rule_key not in _failure_tracker:
99
+ _failure_tracker[rule_key] = [1, time.time(), False]
100
+ else:
101
+ _failure_tracker[rule_key][0] += 1
102
+ _failure_tracker[rule_key][1] = time.time()
103
+ if _failure_tracker[rule_key][0] >= FAILURE_THRESHOLD:
104
+ _failure_tracker[rule_key][2] = True
105
+ log.warning(
106
+ f"callback rule [{rule_key}] failed {FAILURE_THRESHOLD} consecutive times, "
107
+ f"suppressed for {FAILURE_SUPPRESS_SECONDS}s"
108
+ )
109
+
110
+
111
+ def record_success(rule_key: str):
112
+ """Reset failure count after success."""
113
+ _failure_tracker.pop(rule_key, None)
114
+
115
+
116
+ def pick_best_rule(text: str, msg_type: Optional[str],
117
+ callbacks: list[dict]) -> Optional[dict]:
118
+ """Select best matching rule. Single message triggers once only.
119
+
120
+ Match body.type against match_type, pick highest priority.
121
+ Returns None if no match.
122
+ """
123
+ if msg_type:
124
+ type_matches = [r for r in callbacks if r.get("match_type") == msg_type]
125
+ if type_matches:
126
+ return min(type_matches, key=lambda r: r.get("priority", 100))
127
+
128
+ return None
129
+
130
+
131
+ def run_callback(rule: dict, message: dict):
132
+ """Execute callback command with MESSAGE/TYPE/FROM as env vars."""
133
+ command = rule.get("command", "")
134
+ if not command:
135
+ return
136
+
137
+ body = message.get("body", {})
138
+ msg_type = body.get("type", "") if isinstance(body, dict) else ""
139
+ from_ep = message.get("from", "unknown")
140
+ msg_json = json.dumps(message, ensure_ascii=False)
141
+
142
+ rule_key = f"{rule.get('match_type','')}:{rule.get('command','')}"
143
+
144
+ if should_suppress(rule_key):
145
+ return
146
+
147
+ env = os.environ.copy()
148
+ env["MESSAGE"] = msg_json
149
+ env["TYPE"] = msg_type
150
+ env["FROM"] = from_ep
151
+
152
+ try:
153
+ proc = subprocess.Popen(
154
+ command,
155
+ shell=True,
156
+ stdin=subprocess.PIPE,
157
+ stdout=subprocess.DEVNULL,
158
+ stderr=subprocess.PIPE,
159
+ env=env,
160
+ )
161
+ proc.stdin.write(msg_json.encode())
162
+ proc.stdin.close()
163
+ # Let it run asynchronously (no wait)
164
+ record_success(rule_key)
165
+ except Exception as e:
166
+ log.warning(f"callback [{rule_key}] failed: {type(e).__name__}: {e}")
167
+ record_failure(rule_key)
168
+
169
+
170
+ def main():
171
+ # read stdin
172
+ try:
173
+ raw = sys.stdin.read()
174
+ if not raw.strip():
175
+ return
176
+ msg = json.loads(raw)
177
+ except json.JSONDecodeError:
178
+ log.warning("stdin is not valid JSON")
179
+ return
180
+ except Exception as e:
181
+ log.warning(f"stdin read failed: {e}")
182
+ return
183
+
184
+ # Load config
185
+ config = load_yaml_config(CONFIG_PATH)
186
+ callbacks = config.get("callbacks", [])
187
+ if not callbacks:
188
+ return
189
+
190
+ # Extract text and type
191
+ body = msg.get("body", {})
192
+ text = body.get("text", "") if isinstance(body, dict) else str(body)
193
+ msg_type = body.get("type", None) if isinstance(body, dict) else None
194
+
195
+ # type exact match, single trigger
196
+ best = pick_best_rule(text, msg_type, callbacks)
197
+ if best:
198
+ run_callback(best, msg)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """Bus message notification daemon.
3
+
4
+ Listens to Hermes Bus broadcast messages, matches notify.yaml callbacks
5
+ by body.type against match_type (highest priority wins), then executes
6
+ the configured command with MESSAGE / TYPE / FROM as environment variables.
7
+ """
8
+ import json
9
+ import logging
10
+ import os
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from typing import Optional
16
+
17
+ HERMES_HOME = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
18
+ _bus_dir = os.path.join(HERMES_HOME, "hermes-bus")
19
+ if os.path.isdir(_bus_dir):
20
+ sys.path.insert(0, _bus_dir)
21
+ _notify_dir = os.path.join(HERMES_HOME, "hermes-notify")
22
+ DEFAULT_CONFIG = os.path.join(_notify_dir, "notify.yaml")
23
+ DEFAULT_SOCKET = os.path.join(HERMES_HOME, "hermes-bus.sock")
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [%(levelname)s] %(message)s",
28
+ datefmt="%H:%M:%S",
29
+ )
30
+ log = logging.getLogger("bus-notifier")
31
+
32
+
33
+ def load_yaml_config(path: str) -> dict:
34
+ """Load notify.yaml config. Returns dict with callbacks list."""
35
+ if not os.path.exists(path):
36
+ log.error(f"Config file not found: {path}")
37
+ sys.exit(1)
38
+
39
+ with open(path) as f:
40
+ content = f.read()
41
+
42
+ config = {"callbacks": []}
43
+ current_rule = None
44
+
45
+ for line in content.split("\n"):
46
+ stripped = line.strip()
47
+ if not stripped or stripped.startswith("#"):
48
+ continue
49
+
50
+ indent = len(line) - len(line.lstrip())
51
+
52
+ # Top-level callbacks section marker
53
+ if stripped == "callbacks:":
54
+ continue
55
+
56
+ # New callback entry (starts with "- match_type:" or "- command:" etc.)
57
+ if stripped.startswith("- ") and ":" in stripped:
58
+ # Check if this starts a new callback block
59
+ key = stripped[2:].split(":")[0].strip()
60
+ if key in ("match_type", "command", "print", "context", "priority"):
61
+ current_rule = {}
62
+ config["callbacks"].append(current_rule)
63
+
64
+ if current_rule is not None and stripped.startswith("- "):
65
+ key, _, val = stripped[2:].partition(":")
66
+ key = key.strip()
67
+ val = val.strip().strip("'\"")
68
+
69
+ if key == "priority":
70
+ current_rule[key] = int(val)
71
+ elif key in ("print", "context"):
72
+ current_rule[key] = val.lower() in ("true", "yes", "1")
73
+ elif key == "command":
74
+ current_rule[key] = os.path.expanduser(val)
75
+ else:
76
+ current_rule[key] = val
77
+
78
+ elif current_rule is not None and indent >= 4:
79
+ key, _, val = stripped.partition(":")
80
+ key = key.strip()
81
+ val = val.strip().strip("'\"")
82
+
83
+ if key == "command":
84
+ current_rule[key] = os.path.expanduser(val)
85
+ elif key == "priority":
86
+ current_rule[key] = int(val)
87
+ elif key in ("print", "context"):
88
+ current_rule[key] = val.lower() in ("true", "yes", "1")
89
+ elif key == "match_type":
90
+ current_rule[key] = val
91
+ else:
92
+ current_rule[key] = val
93
+
94
+ return config
95
+
96
+
97
+ def pick_best_rule(msg_type: Optional[str], callbacks: list[dict]) -> Optional[dict]:
98
+ """Select best matching callback by body.type exact match against match_type.
99
+ Highest priority (lowest number) wins. Returns None if no match."""
100
+ if not msg_type:
101
+ return None
102
+ type_matches = [r for r in callbacks if r.get("match_type") == msg_type]
103
+ if not type_matches:
104
+ return None
105
+ return min(type_matches, key=lambda r: r.get("priority", 100))
106
+
107
+
108
+ def run_command(rule: dict, msg: dict):
109
+ """Execute the rule's command with MESSAGE/TYPE/FROM as env vars.
110
+
111
+ Also writes the full message JSON to stdin for backward compatibility.
112
+ """
113
+ command = rule.get("command", "")
114
+ if not command:
115
+ return
116
+
117
+ body = msg.get("body", {})
118
+ msg_type = body.get("type", "") if isinstance(body, dict) else ""
119
+ from_ep = msg.get("from", "unknown")
120
+ msg_json = json.dumps(msg, ensure_ascii=False)
121
+
122
+ env = os.environ.copy()
123
+ env["MESSAGE"] = msg_json
124
+ env["TYPE"] = msg_type
125
+ env["FROM"] = from_ep
126
+
127
+ try:
128
+ proc = subprocess.Popen(
129
+ command,
130
+ shell=True,
131
+ stdin=subprocess.PIPE,
132
+ stdout=subprocess.DEVNULL,
133
+ stderr=subprocess.PIPE,
134
+ env=env,
135
+ )
136
+ proc.stdin.write(msg_json.encode())
137
+ proc.stdin.close()
138
+ except Exception as e:
139
+ log.warning(f"Command failed for type [{msg_type}]: {e}")
140
+
141
+
142
+ def main():
143
+ import argparse
144
+
145
+ parser = argparse.ArgumentParser(description="Bus message notification daemon")
146
+ parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
147
+ parser.add_argument("--socket", default=DEFAULT_SOCKET, help="Bus socket path")
148
+ parser.add_argument("--dry-run", action="store_true", help="Match only, do not execute commands")
149
+ parser.add_argument("--poll-interval", type=float, default=1.0, help="Poll interval (seconds)")
150
+ args = parser.parse_args()
151
+
152
+ # Load config
153
+ config = load_yaml_config(args.config)
154
+ callbacks = config.get("callbacks", [])
155
+ if not callbacks:
156
+ log.error("No callbacks found in config file")
157
+ sys.exit(1)
158
+ log.info(f"Loaded {len(callbacks)} callbacks")
159
+
160
+ # Connect to Bus
161
+ log.info(f"Connecting to Bus: {args.socket}")
162
+ try:
163
+ from hermes_bus.client import BusClient
164
+
165
+ client = BusClient("bus-notifier", socket_path=args.socket)
166
+ if not client.connect():
167
+ log.error("Bus connection failed")
168
+ sys.exit(1)
169
+ log.info(f"Bus connected (sid={client.bus_session_id[:8] if client.bus_session_id else '?'})")
170
+ except Exception as e:
171
+ log.error(f"Bus init failed: {e}")
172
+ sys.exit(1)
173
+
174
+ # Signal handler
175
+ running = True
176
+
177
+ def _shutdown(signum, frame):
178
+ nonlocal running
179
+ log.info(f"Received signal {signum}, exiting...")
180
+ running = False
181
+
182
+ signal.signal(signal.SIGTERM, _shutdown)
183
+ signal.signal(signal.SIGINT, _shutdown)
184
+
185
+ # Stats
186
+ total_messages = 0
187
+ total_matched = 0
188
+ total_executed = 0
189
+
190
+ # Listen loop
191
+ log.info("Listening for Bus messages...")
192
+ log.info(f" Callbacks: {len(callbacks)} | Dry-run: {args.dry_run}")
193
+ log.info("-" * 50)
194
+
195
+ while running:
196
+ try:
197
+ msgs = client.poll()
198
+ except Exception as e:
199
+ log.warning(f"Poll exception: {e}")
200
+ time.sleep(args.poll_interval)
201
+ continue
202
+
203
+ for msg in msgs:
204
+ total_messages += 1
205
+
206
+ body = msg.get("body", {})
207
+ text = body.get("text", "") if isinstance(body, dict) else str(body)
208
+ msg_type = body.get("type", None) if isinstance(body, dict) else None
209
+ from_ep = msg.get("from", "unknown")
210
+
211
+ # Match by type
212
+ best = pick_best_rule(msg_type, callbacks)
213
+ if best is None:
214
+ continue
215
+
216
+ total_matched += 1
217
+ rule_type = best.get("match_type", "unknown")
218
+ command = best.get("command", "")
219
+
220
+ log.info(f"Matched [{rule_type}] from={from_ep} text={text[:60]}...")
221
+
222
+ if not command:
223
+ log.info(f" No command configured for type [{rule_type}], skipping")
224
+ continue
225
+
226
+ if args.dry_run:
227
+ log.info(f" [DRY-RUN] Would execute: {command}")
228
+ else:
229
+ log.info(f" Executing: {command}")
230
+ run_command(best, msg)
231
+ total_executed += 1
232
+
233
+ time.sleep(args.poll_interval)
234
+
235
+ # Graceful exit
236
+ log.info("-" * 50)
237
+ log.info(f"Stopped. Total messages: {total_messages} | Matched: {total_matched} | Executed: {total_executed}")
238
+ client.disconnect()
239
+ log.info("Exited")
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """Generic tmux notification tool — sends messages to tmux sessions via send-keys.
3
+
4
+ Sender name resolution:
5
+ 1. --from override (highest priority)
6
+ 2. notify.yaml default_sender config value
7
+
8
+ Usage:
9
+ python3 notify-agent.py --from <sender> <session> <message>
10
+ python3 notify-agent.py --from <sender> --simple <session> <message>
11
+ python3 notify-agent.py <session> <message> # sender from config or none
12
+ """
13
+ import os
14
+ import sys
15
+ import subprocess
16
+ import time
17
+
18
+
19
+ def _get_config_path() -> str:
20
+ home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
21
+ return os.path.join(home, "hermes-notify", "notify.yaml")
22
+
23
+
24
+ def _load_default_sender(config_path: str = None) -> str:
25
+ """Load default_sender from notify.yaml config. Falls back to empty string."""
26
+ path = config_path or _get_config_path()
27
+ if not os.path.exists(path):
28
+ return ""
29
+
30
+ with open(path) as f:
31
+ for line in f:
32
+ stripped = line.strip()
33
+ if stripped.startswith("default_sender:"):
34
+ return stripped.split(":", 1)[1].strip()
35
+ return ""
36
+
37
+
38
+ def main():
39
+ simple_mode = False
40
+ sender = None
41
+ config_path = None
42
+ args = sys.argv[1:]
43
+
44
+ # Parse --config
45
+ if args and args[0] == '--config':
46
+ if len(args) >= 2:
47
+ config_path = args[1]
48
+ args = args[2:]
49
+ else:
50
+ print("Usage: --config <path>", file=sys.stderr)
51
+ sys.exit(1)
52
+
53
+ # Parse --from parameter
54
+ if args and args[0] == '--from':
55
+ if len(args) >= 2:
56
+ sender = args[1]
57
+ args = args[2:]
58
+ else:
59
+ print("Usage: --from <sender_name>", file=sys.stderr)
60
+ sys.exit(1)
61
+
62
+ if args and args[0] == '--simple':
63
+ simple_mode = True
64
+ args = args[1:]
65
+
66
+ if len(args) < 2:
67
+ print("Usage: python3 notify-agent.py [--config <path>] [--from <sender>] [--simple] <session> <message>",
68
+ file=sys.stderr)
69
+ sys.exit(1)
70
+
71
+ session = args[0]
72
+
73
+ if sender:
74
+ message = sender + ': ' + ' '.join(args[1:])
75
+ else:
76
+ default = _load_default_sender(config_path)
77
+ if default:
78
+ message = default + ': ' + ' '.join(args[1:])
79
+ else:
80
+ message = ' '.join(args[1:])
81
+
82
+ # Clear input area first (C-c to interrupt any running task)
83
+ subprocess.run(['tmux', 'send-keys', '-t', session, 'C-c'], timeout=3)
84
+ time.sleep(0.5)
85
+
86
+ # Send message
87
+ subprocess.run(['tmux', 'send-keys', '-t', session, message], timeout=3)
88
+ time.sleep(0.3)
89
+
90
+ # Triple Enter to submit
91
+ for _ in range(3):
92
+ subprocess.run(['tmux', 'send-keys', '-t', session, 'Enter'], timeout=3)
93
+ time.sleep(0.2)
94
+
95
+ print(f'OK: notified {session}')
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """Generic message bus notification sender.
3
+
4
+ Short-lived connection: connect to bus -> send one message -> disconnect.
5
+ Does not register endpoint, does not pollute endpoint_map.
6
+
7
+ Sender name resolution:
8
+ 1. --from manual override (highest priority)
9
+ 2. tmux session -> notify.yaml session_aliases lookup
10
+ 3. raw tmux session name (fallback)
11
+ 4. "notify-hermes" (default)
12
+
13
+ Usage:
14
+ python3 notify-hermes.py --to <endpoint> "message"
15
+ python3 notify-hermes.py --to <endpoint> --type task_done "Task done"
16
+ python3 notify-hermes.py --to <endpoint> --from "MyName" "message"
17
+ python3 notify-hermes.py --to <endpoint> --body '{"text":"hello"}'
18
+ """
19
+ import json
20
+ import os
21
+ import socket
22
+ import struct
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import uuid
27
+
28
+
29
+ def _get_config_path() -> str:
30
+ home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
31
+ return os.path.join(home, "hermes-notify", "notify.yaml")
32
+
33
+
34
+ def _load_config(config_path: str = None) -> dict:
35
+ """Load notify.yaml config. Returns dict with optional session_aliases."""
36
+ path = config_path or _get_config_path()
37
+ config = {}
38
+ if not os.path.exists(path):
39
+ return config
40
+
41
+ with open(path) as f:
42
+ content = f.read()
43
+
44
+ current_section = None
45
+ for line in content.split("\n"):
46
+ stripped = line.strip()
47
+ if not stripped or stripped.startswith("#"):
48
+ continue
49
+ if stripped.endswith(":") and not stripped.startswith("-"):
50
+ current_section = stripped[:-1].strip()
51
+ if current_section == "session_aliases":
52
+ config["session_aliases"] = {}
53
+ elif current_section == "callbacks":
54
+ config["callbacks"] = []
55
+ continue
56
+ if current_section == "session_aliases" and ":" in stripped:
57
+ key, _, val = stripped.partition(":")
58
+ config["session_aliases"][key.strip()] = val.strip()
59
+
60
+ return config
61
+
62
+
63
+ def _resolve_sender_name(override: str = None, config: dict = None) -> str:
64
+ """Determine sender name.
65
+
66
+ Priority: --from manual override > session_aliases lookup > tmux session name > default.
67
+ """
68
+ if override:
69
+ return override
70
+
71
+ aliases = (config or {}).get("session_aliases", {})
72
+
73
+ # Auto-detect from tmux session
74
+ try:
75
+ result = subprocess.run(
76
+ ["tmux", "display-message", "-p", "#S"],
77
+ capture_output=True, text=True, timeout=2,
78
+ )
79
+ if result.returncode == 0:
80
+ session = result.stdout.strip()
81
+ if session:
82
+ return aliases.get(session, session)
83
+ except Exception:
84
+ pass
85
+
86
+ return "notify-hermes"
87
+
88
+
89
+ def _get_bus_socket_path() -> str:
90
+ home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
91
+ return os.path.join(home, "hermes-bus.sock")
92
+
93
+
94
+ def _ensure_bus_running(socket_path: str):
95
+ """Start bus server if not already running."""
96
+ if os.path.exists(socket_path):
97
+ test = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
98
+ test.settimeout(0.5)
99
+ try:
100
+ test.connect(socket_path)
101
+ test.close()
102
+ return
103
+ except Exception:
104
+ try:
105
+ os.unlink(socket_path)
106
+ except Exception:
107
+ pass
108
+ finally:
109
+ test.close()
110
+
111
+ # Run bus server via hermes-busd (uses correct Python with package installed)
112
+ subprocess.Popen(
113
+ ["hermes-busd", "start"],
114
+ stdout=subprocess.DEVNULL,
115
+ stderr=subprocess.DEVNULL,
116
+ stdin=subprocess.DEVNULL,
117
+ start_new_session=True,
118
+ )
119
+ for _ in range(40):
120
+ time.sleep(0.1)
121
+ if os.path.exists(socket_path):
122
+ break
123
+
124
+
125
+ def _send_frame(sock: socket.socket, msg: dict):
126
+ data = json.dumps(msg, ensure_ascii=False).encode("utf-8")
127
+ header = struct.pack(">I", len(data))
128
+ sock.sendall(header + data)
129
+
130
+
131
+ def send_notification(endpoint: str, body: dict, socket_path: str = None,
132
+ from_ep: str = None, config: dict = None) -> bool:
133
+ """Send one message to bus via short-lived connection, then disconnect."""
134
+ sp = socket_path or _get_bus_socket_path()
135
+ _ensure_bus_running(sp)
136
+
137
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
138
+ sock.settimeout(5)
139
+
140
+ try:
141
+ sock.connect(sp)
142
+ except Exception as e:
143
+ print(f"ERROR: Cannot connect to bus: {e}", file=sys.stderr)
144
+ return False
145
+
146
+ sender = _resolve_sender_name(from_ep, config)
147
+ msg = {
148
+ "type": "message",
149
+ "to": endpoint,
150
+ "from": sender,
151
+ "id": str(uuid.uuid4()),
152
+ "ts": time.time(),
153
+ "body": body,
154
+ }
155
+
156
+ try:
157
+ _send_frame(sock, msg)
158
+ sock.settimeout(1.0)
159
+ header = b""
160
+ while len(header) < 4:
161
+ try:
162
+ chunk = sock.recv(4 - len(header))
163
+ except socket.timeout:
164
+ return True
165
+ if not chunk:
166
+ return True
167
+ header += chunk
168
+ if len(header) == 4:
169
+ payload_len = struct.unpack(">I", header)[0]
170
+ payload = b""
171
+ while len(payload) < payload_len:
172
+ try:
173
+ chunk = sock.recv(payload_len - len(payload))
174
+ except socket.timeout:
175
+ break
176
+ if not chunk:
177
+ break
178
+ payload += chunk
179
+ if payload:
180
+ reply = json.loads(payload.decode("utf-8"))
181
+ if reply.get("type") == "error":
182
+ print(f"WARNING: {reply.get('detail', reply)}", file=sys.stderr)
183
+ return False
184
+ return True
185
+ except socket.timeout:
186
+ return True
187
+ except Exception as e:
188
+ print(f"ERROR: {e}", file=sys.stderr)
189
+ return False
190
+ finally:
191
+ try:
192
+ sock.close()
193
+ except Exception:
194
+ pass
195
+
196
+
197
+ def main():
198
+ import argparse
199
+
200
+ parser = argparse.ArgumentParser(
201
+ description="Generic message bus notification sender",
202
+ )
203
+ parser.add_argument(
204
+ "--to",
205
+ required=True,
206
+ help="Target endpoint name (any string, e.g. cli, gateway, my-custom-endpoint)",
207
+ )
208
+ parser.add_argument(
209
+ "message",
210
+ nargs="?",
211
+ default=None,
212
+ help="Message text (ignored if --body is set)",
213
+ )
214
+ parser.add_argument(
215
+ "--body",
216
+ default=None,
217
+ help="JSON body dict (advanced, overrides positional message)",
218
+ )
219
+ parser.add_argument(
220
+ "--socket",
221
+ default=None,
222
+ help="Custom socket path",
223
+ )
224
+ parser.add_argument(
225
+ "--from",
226
+ dest="from_ep",
227
+ default=None,
228
+ help="Override sender name (default: auto-detect from tmux session or config)",
229
+ )
230
+ parser.add_argument(
231
+ "--type",
232
+ choices=["task_start", "task_done", "plan_ready", "task_error", "need_decision", "ack", "progress"],
233
+ default=None,
234
+ help="Message type for callback matching",
235
+ )
236
+ parser.add_argument(
237
+ "--config",
238
+ default=None,
239
+ help="Path to notify.yaml config file (for session_aliases)",
240
+ )
241
+
242
+ args = parser.parse_args()
243
+
244
+ config = _load_config(args.config)
245
+
246
+ if args.body:
247
+ try:
248
+ body = json.loads(args.body)
249
+ except json.JSONDecodeError as e:
250
+ print(f"ERROR: Invalid JSON body: {e}", file=sys.stderr)
251
+ sys.exit(1)
252
+ elif args.message:
253
+ body = {"text": args.message}
254
+ else:
255
+ print("ERROR: Either message text or --body is required", file=sys.stderr)
256
+ sys.exit(1)
257
+
258
+ if args.type:
259
+ body["type"] = args.type
260
+
261
+ success = send_notification(args.to, body, args.socket,
262
+ from_ep=args.from_ep, config=config)
263
+ if success:
264
+ print(f"OK: notified {args.to}")
265
+ else:
266
+ sys.exit(1)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-notify
3
+ Version: 0.1.0
4
+ Summary: A config-driven notification router — rule matching, audio playback, multi-backend command execution
5
+ Author-email: LinQuan <i@linquan.name>
6
+ License: MIT
7
+ Project-URL: homepage, https://github.com/mlinquan/hermes-notify
8
+ Project-URL: repository, https://github.com/mlinquan/hermes-notify
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # hermes-notify
25
+
26
+ [English](./README.md) | [中文](./README.zh.md)
27
+
28
+ A config-driven notification router for Hermes Agent — rule matching, context injection, audio playback, and custom command execution.
29
+
30
+ Transport-agnostic. Reads messages from stdin or a Bus hook, matches against `notify.yaml` rules, and executes the configured action.
31
+
32
+ ## What is this?
33
+
34
+ hermes-notify is a **message router** for the Hermes Agent ecosystem. When a message arrives (from a bus, a script, or stdin), it checks the message type against your `notify.yaml` rules. If a rule matches, it executes the configured command — anything from a macOS notification to a Slack webhook.
35
+
36
+ ### Quickstart
37
+
38
+ 1. Install: `pip install hermes-notify`
39
+ 2. Create a `notify.yaml` config file with a rule (see Configuration below)
40
+ 3. Send a message: `notify-hermes --to my-service --type task_done "Hello"`
41
+ 4. The callback matches `match_type: task_done` and runs your command
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install hermes-notify
47
+ ```
48
+
49
+ Or from source:
50
+
51
+ ```bash
52
+ git clone https://github.com/mlinquan/hermes-notify.git
53
+ cd hermes-notify && pip install -e .
54
+ ```
55
+
56
+ ## CLI
57
+
58
+ ```bash
59
+ # Send a message to any bus endpoint
60
+ notify-hermes --to my-service --type task_done "Task completed"
61
+ notify-hermes --to my-service --type progress "50% done"
62
+ notify-hermes --to my-service --type ack "Received"
63
+
64
+ # Send a notification to a tmux session
65
+ notify-agent mysession "Start working"
66
+ notify-agent --simple mysession "FYI: something happened"
67
+
68
+ # Process a callback message from stdin
69
+ echo '{"body":{"type":"task_done","text":"done"}}' | hermes-callback
70
+ ```
71
+
72
+ ## Configuration
73
+
74
+ Messages are routed by `notify.yaml`. Each callback rule specifies a `match_type` and a `command` to execute:
75
+
76
+ ```yaml
77
+ callbacks:
78
+ - match_type: task_error
79
+ print: false
80
+ context: true
81
+ command: "notify-send 'Task failed'"
82
+
83
+ - match_type: task_done
84
+ print: false
85
+ context: true
86
+ command: "afplay ~/sounds/done.mp3"
87
+ ```
88
+
89
+ Two boolean fields control behavior: `print` (terminal output), `context` (inject into LLM context).
90
+
91
+ The `command` field receives these environment variables:
92
+ - `MESSAGE` — full message JSON
93
+ - `TYPE` — message type
94
+ - `FROM` — sender endpoint name
95
+ - Stdin — raw message JSON (for backward compatibility)
96
+
97
+ Example callback scripts are bundled in `examples/`:
98
+
99
+ ```bash
100
+ examples/macos-notify.py # macOS notification via osascript
101
+ examples/play-sound.py # Play random sound via afplay
102
+ examples/slack-notify.sh # Slack webhook (set SLACK_WEBHOOK_URL)
103
+ ```
104
+
105
+ ## Architecture
106
+
107
+ ```
108
+ stdin / Bus hook ──→ bus_callback.py ──→ notify.yaml rules
109
+
110
+ Match?
111
+ ├─ yes → execute command
112
+ └─ no → silent
113
+
114
+ notify-hermes — Bus message sender
115
+ notify-agent — tmux notification sender
116
+ ```
117
+
118
+ ## Session Aliases
119
+
120
+ Map tmux session names to human-readable sender names in `notify.yaml`:
121
+
122
+ ```yaml
123
+ session_aliases:
124
+ session-1: alias-1
125
+ session-2: alias-2
126
+
127
+ default_sender: notify-agent
128
+ ```
@@ -0,0 +1,11 @@
1
+ hermes_notify/__init__.py,sha256=bzSmSSRtzvqNpY1ECWSruq0J_rUJoYHdcYmuR_gjvmQ,384
2
+ hermes_notify/bus_callback.py,sha256=5AG-NOigqrti2tfBQ-VXWHkuPVaZp9KsH0LeZRykz_4,6244
3
+ hermes_notify/bus_notifier.py,sha256=ZY4UfIMstKlcQvkNgLsvDln6e8DjM7QyBg4__4VI-p8,7921
4
+ hermes_notify/notify_agent.py,sha256=J4Jd0dC-e2gCa1xSz91Tf3qROKDkLhoWWEfnWChp-oM,2817
5
+ hermes_notify/notify_hermes.py,sha256=33gV9x0M-1TM53QJ8PTXT7-0Uc9bPv4NZVo5xqncF1Q,7941
6
+ hermes_notify-0.1.0.dist-info/licenses/LICENSE,sha256=cB1X2pyne7n0Kr9ArfMQYnvCwooBLKfIwYoymMtri9I,1064
7
+ hermes_notify-0.1.0.dist-info/METADATA,sha256=CpgvrsredRmLoApTN5C_I0w1iQ7ejLpog-TSg-CNkvs,4080
8
+ hermes_notify-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ hermes_notify-0.1.0.dist-info/entry_points.txt,sha256=whFMpalXHbvJfhd0xLycUAAUJ04pKW5jw5fmEIAWp1A,164
10
+ hermes_notify-0.1.0.dist-info/top_level.txt,sha256=kpdhujkourxMyFLwZpRgz3SSHXAzxC8_nq_K-emN-B4,14
11
+ hermes_notify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ hermes-callback = hermes_notify.bus_callback:main
3
+ notify-agent = hermes_notify.notify_agent:main
4
+ notify-hermes = hermes_notify.notify_hermes:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LinQuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ hermes_notify