galvanize 0.2.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.
Files changed (46) hide show
  1. galvanize-0.2.0/.github/workflows/ci.yml +28 -0
  2. galvanize-0.2.0/.gitignore +8 -0
  3. galvanize-0.2.0/LICENSE +21 -0
  4. galvanize-0.2.0/PKG-INFO +110 -0
  5. galvanize-0.2.0/README.md +80 -0
  6. galvanize-0.2.0/assets/galvanize-infographic.png +0 -0
  7. galvanize-0.2.0/galvanize/__init__.py +7 -0
  8. galvanize-0.2.0/galvanize/__main__.py +8 -0
  9. galvanize-0.2.0/galvanize/autostart.py +211 -0
  10. galvanize-0.2.0/galvanize/bus.py +76 -0
  11. galvanize-0.2.0/galvanize/cli.py +628 -0
  12. galvanize-0.2.0/galvanize/config.py +235 -0
  13. galvanize-0.2.0/galvanize/daemon.py +222 -0
  14. galvanize-0.2.0/galvanize/dispatch.py +89 -0
  15. galvanize-0.2.0/galvanize/events.py +42 -0
  16. galvanize-0.2.0/galvanize/harnesses.py +141 -0
  17. galvanize-0.2.0/galvanize/hermes.py +251 -0
  18. galvanize-0.2.0/galvanize/manage.py +468 -0
  19. galvanize-0.2.0/galvanize/mcp.py +170 -0
  20. galvanize-0.2.0/galvanize/migrate.py +161 -0
  21. galvanize-0.2.0/galvanize/paths.py +60 -0
  22. galvanize-0.2.0/galvanize/secrets.py +162 -0
  23. galvanize-0.2.0/galvanize/serve.py +240 -0
  24. galvanize-0.2.0/galvanize/sources/__init__.py +1 -0
  25. galvanize-0.2.0/galvanize/sources/folder.py +139 -0
  26. galvanize-0.2.0/galvanize/sources/githook.py +100 -0
  27. galvanize-0.2.0/galvanize/sources/imap.py +256 -0
  28. galvanize-0.2.0/galvanize/sources/relay.py +95 -0
  29. galvanize-0.2.0/galvanize/state.py +88 -0
  30. galvanize-0.2.0/galvanize/template.py +42 -0
  31. galvanize-0.2.0/plugins/hermes/__init__.py +306 -0
  32. galvanize-0.2.0/plugins/hermes/dashboard/dist/index.js +219 -0
  33. galvanize-0.2.0/plugins/hermes/dashboard/manifest.json +13 -0
  34. galvanize-0.2.0/plugins/hermes/dashboard/plugin_api.py +66 -0
  35. galvanize-0.2.0/plugins/hermes/plugin.yaml +11 -0
  36. galvanize-0.2.0/pyproject.toml +48 -0
  37. galvanize-0.2.0/relay/worker.js +84 -0
  38. galvanize-0.2.0/tests/conftest.py +34 -0
  39. galvanize-0.2.0/tests/greenmail/docker-compose.yml +10 -0
  40. galvanize-0.2.0/tests/test_core.py +161 -0
  41. galvanize-0.2.0/tests/test_folder_watch.py +67 -0
  42. galvanize-0.2.0/tests/test_imap_push.py +191 -0
  43. galvanize-0.2.0/tests/test_live_hermes_lane.py +146 -0
  44. galvanize-0.2.0/tests/test_relay.py +116 -0
  45. galvanize-0.2.0/tests/test_serve_doctor.py +107 -0
  46. galvanize-0.2.0/tests/test_template.py +29 -0
@@ -0,0 +1,28 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest, windows-latest, macos-latest]
14
+ python-version: ["3.10", "3.13"]
15
+ runs-on: ${{ matrix.os }}
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install
22
+ run: pip install -e ".[dev]"
23
+ - name: Import smoke
24
+ run: python -c "import galvanize; import galvanize.cli, galvanize.mcp, galvanize.daemon"
25
+ - name: CLI boots
26
+ run: galvanize --version
27
+ - name: Tests
28
+ run: pytest -q
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ .build-notes.md
7
+ design-ref/
8
+ publish/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jarvis Lai
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,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: galvanize
3
+ Version: 0.2.0
4
+ Summary: Event-driven activation points for AI agents: wake a fresh Hermes (or any CLI agent) session when something happens.
5
+ Project-URL: Homepage, https://github.com/jarvis959/galvanize
6
+ Author: Jarvis Lai
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,automation,events,hermes,triggers,webhooks
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Topic :: System :: Monitoring
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: imap-tools<2,>=1.10
24
+ Requires-Dist: keyring<26,>=24
25
+ Requires-Dist: pyyaml<7,>=6.0
26
+ Requires-Dist: watchdog<7,>=4.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ ![galvanize: wake your agents when the world moves](assets/galvanize-infographic.png)
32
+
33
+ **Wake your AI agent when something happens, not on a timer.**
34
+
35
+ galvanize watches the real world (new mail in an inbox, a file landing in a folder, a git commit, a webhook call, an event from a script) and wakes a fresh AI agent session the moment one of those things occurs, with a prompt you wrote. Until now, the only event an agent could see was the clock: "trigger me when X" always became an hourly cron poll, because polling was the only surface the agent had. galvanize puts real event triggers directly into the agent's own tool list (native plugin for Hermes, MCP server for Claude Code and Codex), so when you say "wake me when resumes land", the agent wires an actual push trigger instead. Passwords go to your OS keyring, each trigger starts watching in seconds, and results arrive wherever you asked (Telegram, Discord, or just the log).
36
+
37
+ ## Install
38
+
39
+ Works on Windows, macOS, and Linux (Python 3.10+). No cloning needed — one command installs straight from GitHub:
40
+
41
+ ```bash
42
+ pipx install "git+https://github.com/jarvis959/galvanize.git"
43
+ # or: uv tool install --from "git+https://github.com/jarvis959/galvanize.git" galvanize
44
+ # or: pip install git+https://github.com/jarvis959/galvanize.git
45
+ ```
46
+
47
+ Then run setup once:
48
+
49
+ ```bash
50
+ galvanize init
51
+ ```
52
+
53
+ `init` enables the Hermes webhook platform (backs up your config first), installs the agent plugin, registers the daemon to start at login, and auto-registers the MCP tool surface into Claude Code / Codex if it finds them on this machine. Everything is confirmed on screen and reversible; `--yes` accepts defaults.
54
+
55
+ After that, the whole surface is five verbs:
56
+
57
+ ```bash
58
+ galvanize add folder ~/watch --wake hermes # + a live test fire
59
+ galvanize add imap you@gmail.com --wake hermes # app-password to keyring
60
+ galvanize status # watching? last fire, errors, health
61
+ galvanize doctor # deep health check
62
+ galvanize test cad-drops # inject a synthetic event through the real path
63
+ ```
64
+
65
+ ## Why
66
+
67
+ Every "trigger me when X" conversation defaults to an hourly cron poller, because polling is the only surface the agent can see. galvanize puts event triggers *in the agent's own tool list*, so when you say "wake me when resumes land", the agent wires a real push trigger instead of a poll job.
68
+
69
+ - **Fresh sessions, not thread injections.** Each event spawns a clean one-shot run: no context pollution, results delivered where you asked.
70
+ - **Management everywhere cron is managed.** Dashboard `/triggers` tab, `/triggers` slash command, `hermes triggers` CLI, agent tools, plus `galvanize status` / `doctor` / `daemon`: all surfaces, one ops core.
71
+ - **Push email that survives real life.** IDLE with 25-min re-arm, UID dedupe, reconnect catch-up, keyring-held credentials, and a `migrate hermes-cron` command that converts your existing email pollers.
72
+ - **Zero-inbound-port webhooks.** Optional user-owned Cloudflare Worker relay (`relay/worker.js`): services POST to your URL, your laptop pulls the queue.
73
+
74
+ ## Working with each harness
75
+
76
+ ### Hermes
77
+
78
+ `galvanize init` does the setup: enables `platforms.webhook` in your `config.yaml` (backup saved first), pip-installs the package into the interpreter Hermes runs in, copies the plugin into `~/.hermes/plugins` and enables it. Restart the Hermes gateway once to load the webhook platform; the `trigger_*` tools appear in new sessions. From then on, "wake me when a file lands in ~/cad-drops" creates a real trigger in conversation.
79
+
80
+ ### Claude Code / Codex
81
+
82
+ `init` writes the MCP server entry into `~/.claude.json` / `~/.codex/config.toml` automatically when it finds them (Codex gets `default_tools_approval_mode = "approve"` pre-declared, as newer Codex builds otherwise hide the tools). New session: `trigger_add` and friends are in the tool list. Wake presets:
83
+
84
+ ```bash
85
+ galvanize add folder ~/inbox --wake claude # claude -p "{prompt}"
86
+ galvanize add folder ~/inbox --wake codex # codex exec (sandbox pre-declared)
87
+ galvanize add git-hook ~/code/myrepo --wake codex # wake Codex on commits
88
+ ```
89
+
90
+ ### Any other CLI agent
91
+
92
+ ```bash
93
+ galvanize add folder ~/watch --wake shell --command 'myagent run "{prompt}"'
94
+ ```
95
+
96
+ ## Daily use
97
+
98
+ Triggers the agent creates itself with its `trigger_add` tool are the primary path; the CLI is the no-agent fallback. Both write the same `~/.galvanize/triggers.yaml`, and the dashboard tab manages what either creates (creation stays conversation-first by design).
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ git clone https://github.com/jarvis959/galvanize && cd galvanize
104
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]" # Scripts/ on Windows
105
+ .venv/bin/pytest # unit suite; live-lane + docker tests skip cleanly
106
+ ```
107
+
108
+ The live Hermes-lane test runs inside a Hermes checkout's venv (`pytest tests/test_live_hermes_lane.py`); the IMAP suite drives a GreenMail container and skips when Docker is unavailable.
109
+
110
+ MIT licensed. Built for the Hermes ecosystem; architecture is harness-neutral.
@@ -0,0 +1,80 @@
1
+ ![galvanize: wake your agents when the world moves](assets/galvanize-infographic.png)
2
+
3
+ **Wake your AI agent when something happens, not on a timer.**
4
+
5
+ galvanize watches the real world (new mail in an inbox, a file landing in a folder, a git commit, a webhook call, an event from a script) and wakes a fresh AI agent session the moment one of those things occurs, with a prompt you wrote. Until now, the only event an agent could see was the clock: "trigger me when X" always became an hourly cron poll, because polling was the only surface the agent had. galvanize puts real event triggers directly into the agent's own tool list (native plugin for Hermes, MCP server for Claude Code and Codex), so when you say "wake me when resumes land", the agent wires an actual push trigger instead. Passwords go to your OS keyring, each trigger starts watching in seconds, and results arrive wherever you asked (Telegram, Discord, or just the log).
6
+
7
+ ## Install
8
+
9
+ Works on Windows, macOS, and Linux (Python 3.10+). No cloning needed — one command installs straight from GitHub:
10
+
11
+ ```bash
12
+ pipx install "git+https://github.com/jarvis959/galvanize.git"
13
+ # or: uv tool install --from "git+https://github.com/jarvis959/galvanize.git" galvanize
14
+ # or: pip install git+https://github.com/jarvis959/galvanize.git
15
+ ```
16
+
17
+ Then run setup once:
18
+
19
+ ```bash
20
+ galvanize init
21
+ ```
22
+
23
+ `init` enables the Hermes webhook platform (backs up your config first), installs the agent plugin, registers the daemon to start at login, and auto-registers the MCP tool surface into Claude Code / Codex if it finds them on this machine. Everything is confirmed on screen and reversible; `--yes` accepts defaults.
24
+
25
+ After that, the whole surface is five verbs:
26
+
27
+ ```bash
28
+ galvanize add folder ~/watch --wake hermes # + a live test fire
29
+ galvanize add imap you@gmail.com --wake hermes # app-password to keyring
30
+ galvanize status # watching? last fire, errors, health
31
+ galvanize doctor # deep health check
32
+ galvanize test cad-drops # inject a synthetic event through the real path
33
+ ```
34
+
35
+ ## Why
36
+
37
+ Every "trigger me when X" conversation defaults to an hourly cron poller, because polling is the only surface the agent can see. galvanize puts event triggers *in the agent's own tool list*, so when you say "wake me when resumes land", the agent wires a real push trigger instead of a poll job.
38
+
39
+ - **Fresh sessions, not thread injections.** Each event spawns a clean one-shot run: no context pollution, results delivered where you asked.
40
+ - **Management everywhere cron is managed.** Dashboard `/triggers` tab, `/triggers` slash command, `hermes triggers` CLI, agent tools, plus `galvanize status` / `doctor` / `daemon`: all surfaces, one ops core.
41
+ - **Push email that survives real life.** IDLE with 25-min re-arm, UID dedupe, reconnect catch-up, keyring-held credentials, and a `migrate hermes-cron` command that converts your existing email pollers.
42
+ - **Zero-inbound-port webhooks.** Optional user-owned Cloudflare Worker relay (`relay/worker.js`): services POST to your URL, your laptop pulls the queue.
43
+
44
+ ## Working with each harness
45
+
46
+ ### Hermes
47
+
48
+ `galvanize init` does the setup: enables `platforms.webhook` in your `config.yaml` (backup saved first), pip-installs the package into the interpreter Hermes runs in, copies the plugin into `~/.hermes/plugins` and enables it. Restart the Hermes gateway once to load the webhook platform; the `trigger_*` tools appear in new sessions. From then on, "wake me when a file lands in ~/cad-drops" creates a real trigger in conversation.
49
+
50
+ ### Claude Code / Codex
51
+
52
+ `init` writes the MCP server entry into `~/.claude.json` / `~/.codex/config.toml` automatically when it finds them (Codex gets `default_tools_approval_mode = "approve"` pre-declared, as newer Codex builds otherwise hide the tools). New session: `trigger_add` and friends are in the tool list. Wake presets:
53
+
54
+ ```bash
55
+ galvanize add folder ~/inbox --wake claude # claude -p "{prompt}"
56
+ galvanize add folder ~/inbox --wake codex # codex exec (sandbox pre-declared)
57
+ galvanize add git-hook ~/code/myrepo --wake codex # wake Codex on commits
58
+ ```
59
+
60
+ ### Any other CLI agent
61
+
62
+ ```bash
63
+ galvanize add folder ~/watch --wake shell --command 'myagent run "{prompt}"'
64
+ ```
65
+
66
+ ## Daily use
67
+
68
+ Triggers the agent creates itself with its `trigger_add` tool are the primary path; the CLI is the no-agent fallback. Both write the same `~/.galvanize/triggers.yaml`, and the dashboard tab manages what either creates (creation stays conversation-first by design).
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ git clone https://github.com/jarvis959/galvanize && cd galvanize
74
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]" # Scripts/ on Windows
75
+ .venv/bin/pytest # unit suite; live-lane + docker tests skip cleanly
76
+ ```
77
+
78
+ The live Hermes-lane test runs inside a Hermes checkout's venv (`pytest tests/test_live_hermes_lane.py`); the IMAP suite drives a GreenMail container and skips when Docker is unavailable.
79
+
80
+ MIT licensed. Built for the Hermes ecosystem; architecture is harness-neutral.
@@ -0,0 +1,7 @@
1
+ """Galvanize — event-driven activation points for AI agents.
2
+
3
+ Wake a fresh agent session when something happens in the world:
4
+ a file lands in a folder, a webhook fires, a script emits an event.
5
+ """
6
+
7
+ __version__ = "0.2.0"
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m galvanize`` as an alias for the CLI entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,211 @@
1
+ """Start-at-login install for the galvanize daemon.
2
+
3
+ User-facing behavior (PLAN §5): after `galvanize init`, the daemon is
4
+ registered to start at login and running right now. Mechanisms per OS:
5
+ Windows : Task Scheduler, per-user onlogon task (no admin, hidden window)
6
+ Linux : systemd user unit (+ enable --now; lingering hint if no session)
7
+ macOS : LaunchAgent plist + launchctl bootstrap
8
+
9
+ Everything is per-user, reversible (`daemon remove`), and prints exactly
10
+ what it did. The task/agent runs `galvanize run`; hot state lives in
11
+ ~/.galvanize so a reboot resumes all triggers.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import plistlib
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from .paths import ensure_home
23
+
24
+
25
+ TASK_NAME = "GalvanizeTriggerDaemon"
26
+ UNIT_NAME = "galvanize"
27
+
28
+
29
+ def _galvanize_run_cmd() -> list[str]:
30
+ """Interpreter + args that run `galvanize run` with no console window.
31
+
32
+ Handles both launch shapes: `python -m galvanize` (sys.executable is the
33
+ interpreter) and the installed `galvanize.exe` launcher (find python(.w)
34
+ next to it). Prefers pythonw.exe on Windows — windowless.
35
+ """
36
+ exe = Path(sys.executable)
37
+ if exe.name.lower() not in ("python.exe", "pythonw.exe"):
38
+ # frozen entry-point launcher -> sibling interpreter in Scripts/..
39
+ cand = exe.parent.parent / "Scripts" / "python.exe"
40
+ if not cand.exists():
41
+ cand = exe.parent / "python.exe"
42
+ if cand.exists():
43
+ exe = cand
44
+ if os.name == "nt":
45
+ windowless = exe.with_name("pythonw.exe")
46
+ if windowless.exists():
47
+ exe = windowless
48
+ return [str(exe), "-m", "galvanize.cli", "run"]
49
+
50
+
51
+ # ------------------------------------------------------------------ Windows
52
+
53
+ def _startup_lnk() -> Path:
54
+ return Path(os.environ.get("APPDATA", str(Path.home()))) / \
55
+ "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / "Galvanize.lnk"
56
+
57
+
58
+ def _write_startup_shortcut() -> None:
59
+ """Shell:startup .lnk via PowerShell WScript.Shell (no admin needed)."""
60
+ target, *arglist = _galvanize_run_cmd()
61
+ args = " ".join(arglist)
62
+ lnk = _startup_lnk()
63
+ ps = (
64
+ "$s=(New-Object -ComObject WScript.Shell).CreateShortcut('%s');"
65
+ "$s.TargetPath='%s';$s.Arguments='%s';$s.WorkingDirectory='%s';$s.WindowStyle=7;$s.Save()"
66
+ % (str(lnk).replace("'", "''"), target, args,
67
+ str(ensure_home()).replace("'", "''"))
68
+ )
69
+ subprocess.run(["powershell", "-NoProfile", "-Command", ps],
70
+ capture_output=True, text=True, check=True)
71
+
72
+
73
+ def _win_install() -> tuple[bool, list[str]]:
74
+ # Preferred: Startup-folder shortcut — per-user, no elevation.
75
+ try:
76
+ _write_startup_shortcut()
77
+ lines = ["✔ Daemon will start at login (Startup shortcut created)."]
78
+ # start it right now, detached, windowless
79
+ creationflags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | NEW_PROCESS_GROUP
80
+ subprocess.Popen(_win_startup_cmd(), stdout=subprocess.DEVNULL,
81
+ stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
82
+ creationflags=creationflags)
83
+ lines.append("✔ Daemon started now.")
84
+ return True, lines
85
+ except Exception as e:
86
+ return False, [f"✖ Startup shortcut failed: {e}",
87
+ " Fallback: galvanize run in a terminal, or 'galvanize daemon install' as admin"]
88
+
89
+
90
+ def _win_startup_cmd() -> list[str]:
91
+ """Direct detached run (pythonw -m galvanize.cli run)."""
92
+ return _galvanize_run_cmd()
93
+
94
+
95
+ def _win_remove() -> tuple[bool, list[str]]:
96
+ ok = True
97
+ lines = []
98
+ lnk = _startup_lnk()
99
+ try:
100
+ lnk.unlink(missing_ok=True)
101
+ lines.append("✔ Removed Startup shortcut (won't start at login).")
102
+ except OSError as e:
103
+ ok = False
104
+ lines.append(f"✖ Could not remove Startup shortcut: {e}")
105
+ # also clear a legacy schtasks entry if one exists
106
+ subprocess.run(["schtasks", "/End", "/TN", TASK_NAME], capture_output=True)
107
+ subprocess.run(["schtasks", "/Delete", "/F", "/TN", TASK_NAME], capture_output=True)
108
+ return ok, lines
109
+
110
+
111
+ def _win_installed() -> bool:
112
+ return _startup_lnk().exists()
113
+
114
+
115
+ # ------------------------------------------------------------------ Linux
116
+
117
+ def _unit_path() -> Path:
118
+ return Path.home() / ".config" / "systemd" / "user" / f"{UNIT_NAME}.service"
119
+
120
+
121
+ def _linux_install() -> tuple[bool, list[str]]:
122
+ lines = []
123
+ exe = " ".join(_galvanize_run_cmd())
124
+ unit = (
125
+ "[Unit]\nDescription=Galvanize trigger daemon\n\n"
126
+ "[Service]\nExecStart=" + exe + "\nRestart=on-failure\n\n"
127
+ "[Install]\nWantedBy=default.target\n"
128
+ )
129
+ _unit_path().parent.mkdir(parents=True, exist_ok=True)
130
+ _unit_path().write_text(unit, encoding="utf-8")
131
+ p = subprocess.run(["systemctl", "--user", "enable", "--now", f"{UNIT_NAME}.service"],
132
+ capture_output=True, text=True)
133
+ if p.returncode == 0:
134
+ return True, ["✔ systemd user service enabled and started.",
135
+ " (Server/logind headless: run 'loginctl enable-linger $USER' to keep it up at logout.)"]
136
+ return False, [f"✖ systemctl failed: {(p.stderr or p.stdout).strip()[:200]}",
137
+ f" Unit written to {_unit_path()} — enable manually."]
138
+
139
+
140
+ def _linux_remove() -> tuple[bool, list[str]]:
141
+ subprocess.run(["systemctl", "--user", "disable", "--now", f"{UNIT_NAME}.service"],
142
+ capture_output=True)
143
+ try:
144
+ _unit_path().unlink(missing_ok=True)
145
+ except OSError:
146
+ pass
147
+ return True, ["✔ systemd user service removed."]
148
+
149
+
150
+ def _linux_installed() -> bool:
151
+ return _unit_path().exists()
152
+
153
+
154
+ # ------------------------------------------------------------------ macOS
155
+
156
+ def _plist_path() -> Path:
157
+ return Path.home() / "Library" / "LaunchAgents" / f"agent.galvanize.{UNIT_NAME}.plist"
158
+
159
+
160
+ def _macos_install() -> tuple[bool, list[str]]:
161
+ plist = {
162
+ "Label": f"agent.galvanize.{UNIT_NAME}",
163
+ "ProgramArguments": _galvanize_run_cmd(),
164
+ "RunAtLoad": True,
165
+ "KeepAlive": {"SuccessfulExit": False},
166
+ }
167
+ _plist_path().parent.mkdir(parents=True, exist_ok=True)
168
+ with open(_plist_path(), "wb") as fh:
169
+ plistlib.dump(plist, fh)
170
+ p = subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(_plist_path())],
171
+ capture_output=True, text=True)
172
+ if p.returncode == 0 or "already loaded" in (p.stderr or "").lower():
173
+ return True, ["✔ LaunchAgent installed and loaded."]
174
+ return False, [f"✖ launchctl failed: {(p.stderr or p.stdout).strip()[:200]}"]
175
+
176
+
177
+ def _macos_remove() -> tuple[bool, list[str]]:
178
+ subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(_plist_path())],
179
+ capture_output=True)
180
+ try:
181
+ _plist_path().unlink(missing_ok=True)
182
+ except OSError:
183
+ pass
184
+ return True, ["✔ LaunchAgent removed."]
185
+
186
+
187
+ def _macos_installed() -> bool:
188
+ return _plist_path().exists()
189
+
190
+
191
+ # ------------------------------------------------------------------ facade
192
+
193
+ def _os() -> str:
194
+ if sys.platform == "win32":
195
+ return "win"
196
+ if sys.platform == "darwin":
197
+ return "macos"
198
+ return "linux"
199
+
200
+
201
+ def install() -> tuple[bool, list[str]]:
202
+ ensure_home()
203
+ return {"win": _win_install, "linux": _linux_install, "macos": _macos_install}[_os()]()
204
+
205
+
206
+ def remove() -> tuple[bool, list[str]]:
207
+ return {"win": _win_remove, "linux": _linux_remove, "macos": _macos_remove}[_os()]()
208
+
209
+
210
+ def installed() -> bool:
211
+ return {"win": _win_installed, "linux": _linux_installed, "macos": _macos_installed}[_os()]()
@@ -0,0 +1,76 @@
1
+ """The Trigger Bus: dedupe + cooldown, then dispatch.
2
+
3
+ Every source funnels normalized events through here so the guards live in
4
+ exactly one place:
5
+ - dedupe_key: repeated events carrying the same key collapse to one wake
6
+ (folder-watch double-fires, re-adding a file, source+lane overlap)
7
+ - cooldown_s: minimum seconds between two wakes for one trigger
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import threading
14
+ import time
15
+ from typing import Dict, Optional, Tuple
16
+
17
+ from . import dispatch as dispatch_mod
18
+ from .config import Trigger, load_triggers
19
+ from .events import Event
20
+ from .template import render
21
+
22
+ logger = logging.getLogger("galvanize.bus")
23
+
24
+
25
+ class TriggerBus:
26
+ def __init__(self) -> None:
27
+ self._lock = threading.Lock()
28
+ self._last_fire: Dict[str, float] = {} # successful dispatches
29
+ self._last_attempt: Dict[str, float] = {} # for cooldown throttling
30
+ self._seen_keys: Dict[str, Tuple[float, str]] = {} # trigger -> (ts, key)
31
+
32
+ def _gate(self, t: Trigger, event: Event) -> Tuple[Optional[str], Optional[str]]:
33
+ """Return (skip_reason, pending_dedupe_key). skip_reason None -> fire."""
34
+ now = time.time()
35
+ pending: Optional[str] = None
36
+ with self._lock:
37
+ if t.dedupe_key:
38
+ key = render(t.dedupe_key, event.payload)
39
+ pending = key
40
+ prev = self._seen_keys.get(t.name)
41
+ if prev and prev[1] == key and (
42
+ not t.cooldown_s or now - prev[0] < t.cooldown_s
43
+ ):
44
+ return f"dedupe '{key}'", None
45
+ if t.cooldown_s:
46
+ last = self._last_attempt.get(t.name, 0.0)
47
+ if now - last < t.cooldown_s:
48
+ return f"cooldown ({int(now - last)}s < {int(t.cooldown_s)}s)", None
49
+ self._last_attempt[t.name] = now
50
+ return None, pending
51
+
52
+ def handle(self, t: Trigger, event: Event) -> Tuple[bool, str]:
53
+ """Handle an event for a trigger. Returns (ok_or_skipped, detail)."""
54
+ if not t.enabled:
55
+ return True, "trigger disabled"
56
+ reason, pending_key = self._gate(t, event)
57
+ if reason:
58
+ logger.info("skip %s: %s", t.name, reason)
59
+ return True, f"skipped: {reason}"
60
+ prompt = render(t.prompt, event.payload)
61
+ ok, detail = dispatch_mod.dispatch(t, event, prompt)
62
+ if ok:
63
+ with self._lock:
64
+ self._last_fire[t.name] = time.time()
65
+ if pending_key is not None:
66
+ self._seen_keys[t.name] = (time.time(), pending_key)
67
+ else:
68
+ logger.warning("dispatch failed for %s: %s", t.name, detail)
69
+ return ok, detail
70
+
71
+ def handle_named(self, trigger_name: str, event: Event) -> Tuple[bool, str]:
72
+ ts = load_triggers()
73
+ t = ts.get(trigger_name)
74
+ if t is None:
75
+ return False, f"unknown trigger '{trigger_name}'"
76
+ return self.handle(t, event)