hermes-notify 0.1.0__tar.gz
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.
- hermes_notify-0.1.0/LICENSE +21 -0
- hermes_notify-0.1.0/PKG-INFO +128 -0
- hermes_notify-0.1.0/README.md +105 -0
- hermes_notify-0.1.0/hermes_notify/__init__.py +13 -0
- hermes_notify-0.1.0/hermes_notify/bus_callback.py +202 -0
- hermes_notify-0.1.0/hermes_notify/bus_notifier.py +243 -0
- hermes_notify-0.1.0/hermes_notify/notify_agent.py +99 -0
- hermes_notify-0.1.0/hermes_notify/notify_hermes.py +270 -0
- hermes_notify-0.1.0/hermes_notify.egg-info/PKG-INFO +128 -0
- hermes_notify-0.1.0/hermes_notify.egg-info/SOURCES.txt +13 -0
- hermes_notify-0.1.0/hermes_notify.egg-info/dependency_links.txt +1 -0
- hermes_notify-0.1.0/hermes_notify.egg-info/entry_points.txt +4 -0
- hermes_notify-0.1.0/hermes_notify.egg-info/top_level.txt +1 -0
- hermes_notify-0.1.0/pyproject.toml +37 -0
- hermes_notify-0.1.0/setup.cfg +4 -0
|
@@ -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,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,105 @@
|
|
|
1
|
+
# hermes-notify
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [中文](./README.zh.md)
|
|
4
|
+
|
|
5
|
+
A config-driven notification router for Hermes Agent — rule matching, context injection, audio playback, and custom command execution.
|
|
6
|
+
|
|
7
|
+
Transport-agnostic. Reads messages from stdin or a Bus hook, matches against `notify.yaml` rules, and executes the configured action.
|
|
8
|
+
|
|
9
|
+
## What is this?
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
### Quickstart
|
|
14
|
+
|
|
15
|
+
1. Install: `pip install hermes-notify`
|
|
16
|
+
2. Create a `notify.yaml` config file with a rule (see Configuration below)
|
|
17
|
+
3. Send a message: `notify-hermes --to my-service --type task_done "Hello"`
|
|
18
|
+
4. The callback matches `match_type: task_done` and runs your command
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install hermes-notify
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or from source:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/mlinquan/hermes-notify.git
|
|
30
|
+
cd hermes-notify && pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CLI
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Send a message to any bus endpoint
|
|
37
|
+
notify-hermes --to my-service --type task_done "Task completed"
|
|
38
|
+
notify-hermes --to my-service --type progress "50% done"
|
|
39
|
+
notify-hermes --to my-service --type ack "Received"
|
|
40
|
+
|
|
41
|
+
# Send a notification to a tmux session
|
|
42
|
+
notify-agent mysession "Start working"
|
|
43
|
+
notify-agent --simple mysession "FYI: something happened"
|
|
44
|
+
|
|
45
|
+
# Process a callback message from stdin
|
|
46
|
+
echo '{"body":{"type":"task_done","text":"done"}}' | hermes-callback
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Messages are routed by `notify.yaml`. Each callback rule specifies a `match_type` and a `command` to execute:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
callbacks:
|
|
55
|
+
- match_type: task_error
|
|
56
|
+
print: false
|
|
57
|
+
context: true
|
|
58
|
+
command: "notify-send 'Task failed'"
|
|
59
|
+
|
|
60
|
+
- match_type: task_done
|
|
61
|
+
print: false
|
|
62
|
+
context: true
|
|
63
|
+
command: "afplay ~/sounds/done.mp3"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Two boolean fields control behavior: `print` (terminal output), `context` (inject into LLM context).
|
|
67
|
+
|
|
68
|
+
The `command` field receives these environment variables:
|
|
69
|
+
- `MESSAGE` — full message JSON
|
|
70
|
+
- `TYPE` — message type
|
|
71
|
+
- `FROM` — sender endpoint name
|
|
72
|
+
- Stdin — raw message JSON (for backward compatibility)
|
|
73
|
+
|
|
74
|
+
Example callback scripts are bundled in `examples/`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
examples/macos-notify.py # macOS notification via osascript
|
|
78
|
+
examples/play-sound.py # Play random sound via afplay
|
|
79
|
+
examples/slack-notify.sh # Slack webhook (set SLACK_WEBHOOK_URL)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Architecture
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
stdin / Bus hook ──→ bus_callback.py ──→ notify.yaml rules
|
|
86
|
+
│
|
|
87
|
+
Match?
|
|
88
|
+
├─ yes → execute command
|
|
89
|
+
└─ no → silent
|
|
90
|
+
|
|
91
|
+
notify-hermes — Bus message sender
|
|
92
|
+
notify-agent — tmux notification sender
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Session Aliases
|
|
96
|
+
|
|
97
|
+
Map tmux session names to human-readable sender names in `notify.yaml`:
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
session_aliases:
|
|
101
|
+
session-1: alias-1
|
|
102
|
+
session-2: alias-2
|
|
103
|
+
|
|
104
|
+
default_sender: notify-agent
|
|
105
|
+
```
|
|
@@ -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,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hermes_notify/__init__.py
|
|
5
|
+
hermes_notify/bus_callback.py
|
|
6
|
+
hermes_notify/bus_notifier.py
|
|
7
|
+
hermes_notify/notify_agent.py
|
|
8
|
+
hermes_notify/notify_hermes.py
|
|
9
|
+
hermes_notify.egg-info/PKG-INFO
|
|
10
|
+
hermes_notify.egg-info/SOURCES.txt
|
|
11
|
+
hermes_notify.egg-info/dependency_links.txt
|
|
12
|
+
hermes_notify.egg-info/entry_points.txt
|
|
13
|
+
hermes_notify.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hermes_notify
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hermes-notify"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A config-driven notification router — rule matching, audio playback, multi-backend command execution"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
authors = [{name = "LinQuan", email = "i@linquan.name"}]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = []
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
homepage = "https://github.com/mlinquan/hermes-notify"
|
|
25
|
+
repository = "https://github.com/mlinquan/hermes-notify"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
notify-hermes = "hermes_notify.notify_hermes:main"
|
|
29
|
+
notify-agent = "hermes_notify.notify_agent:main"
|
|
30
|
+
hermes-callback = "hermes_notify.bus_callback:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["hermes_notify*"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools", "wheel"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|