galvanize 0.2.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.
galvanize/__init__.py ADDED
@@ -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"
galvanize/__main__.py ADDED
@@ -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,306 @@
1
+ """Galvanize Hermes plugin — trigger management tools for the agent.
2
+
3
+ Why this exists: the agent's tool list shapes its habits. cronjob sits in
4
+ schema every turn, so "watch for X" becomes an hourly poller. These tools
5
+ give event-driven activation the same prominence. Wake-up itself stays
6
+ external (the galvanize daemon / the Hermes webhook lane); this plugin is
7
+ the management surface only.
8
+
9
+ Tool handlers try `import galvanize` (same interpreter when galvanize is
10
+ installed into Hermes' venv) and fall back to the `galvanize` CLI on PATH,
11
+ so the plugin degrades gracefully when the daemon lives elsewhere.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _run_op(op: str, **kwargs):
26
+ """Call a galvanize manage op, in-process if possible, CLI otherwise."""
27
+ try:
28
+ from galvanize import manage
29
+
30
+ fn = {
31
+ "add": manage.add_trigger,
32
+ "remove": manage.remove_trigger_op,
33
+ "test": manage.test_trigger,
34
+ "status": manage.status,
35
+ "list": None,
36
+ }[op]
37
+ if op == "list":
38
+ from galvanize.config import load_triggers
39
+
40
+ ts = load_triggers()
41
+ return {"ok": True, "triggers": {n: t.to_dict() for n, t in ts.items()}}
42
+ return fn(**kwargs)
43
+ except ImportError:
44
+ if not shutil.which("galvanize"):
45
+ return {"ok": False, "error":
46
+ "galvanize is not installed in this interpreter and 'galvanize' "
47
+ "is not on PATH. Fix: <hermes-python> -m pip install "
48
+ "\"git+https://github.com/jarvis959/galvanize.git\" (or run: galvanize init)"}
49
+ cmd = ["galvanize"]
50
+ cmd += {"add": ["add"], "remove": ["remove"], "test": ["test"],
51
+ "status": ["status"], "list": ["status"]}[op]
52
+ # CLI fallback: reconstruct the arg vector so trigger_add still works
53
+ # when the package isn't importable in the gateway interpreter.
54
+ if op == "add":
55
+ kind = kwargs.get("kind", "")
56
+ target = kwargs.get("target", "")
57
+ if not kind:
58
+ return {"ok": False, "error": "trigger_add needs 'kind'"}
59
+ cmd += [kind] + ([target] if target else [])
60
+ for flag, key in (("--name", "name"), ("--wake", "wake"),
61
+ ("--deliver", "deliver"), ("--prompt", "prompt"),
62
+ ("--workdir", "workdir"), ("--command", "command")):
63
+ val = kwargs.get(key, "")
64
+ if val:
65
+ cmd += [flag, str(val)]
66
+ if kwargs.get("patterns"):
67
+ cmd += ["--patterns", ",".join(kwargs["patterns"])]
68
+ if kwargs.get("events"):
69
+ cmd += ["--events", ",".join(kwargs["events"])]
70
+ if kwargs.get("cooldown_s"):
71
+ cmd += ["--cooldown", str(kwargs["cooldown_s"])]
72
+ elif op == "remove":
73
+ cmd += [kwargs.get("name", "")]
74
+ elif op == "test":
75
+ cmd += [kwargs.get("name", "")]
76
+ if kwargs.get("payload"):
77
+ cmd += ["--payload", json.dumps(kwargs["payload"])]
78
+ try:
79
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60,
80
+ encoding="utf-8", errors="replace")
81
+ return {"ok": proc.returncode == 0,
82
+ "output": (proc.stdout or proc.stderr)[-3000:]}
83
+ except Exception as e:
84
+ return {"ok": False, "error": str(e)}
85
+
86
+
87
+ # ---------------------------------------------------------------- schemas
88
+
89
+ ADD_SCHEMA = {
90
+ "name": "trigger_add",
91
+ "description": (
92
+ "Create an event trigger that wakes a fresh Hermes session when something "
93
+ "happens. USE THIS INSTEAD OF a scheduled/polling cron job whenever the user "
94
+ "describes an EVENT: 'wake me when X lands/arrives/happens', 'notify me when "
95
+ "a file shows up', 'when GitHub pings me...'. Sources: "
96
+ "folder (files landing in a directory — {file} and {path} usable in prompt), "
97
+ "webhook (external service POSTs to a URL we host: GitHub, Stripe, monitoring), "
98
+ "emit (named event fired by scripts or other agents via `galvanize emit`). "
99
+ "Results deliver per-trigger (deliver='telegram'|'discord'|...|'log')."
100
+ ),
101
+ "parameters": {
102
+ "type": "object",
103
+ "properties": {
104
+ "kind": {"type": "string", "enum": ["folder", "webhook", "emit"]},
105
+ "name": {"type": "string", "description": "lowercase-kebab trigger name"},
106
+ "target": {"type": "string", "description": "folder path to watch (kind=folder)"},
107
+ "prompt": {"type": "string", "description": "instruction for the woken session; placeholders {file} {path} or webhook payload fields like {pull_request.title}"},
108
+ "patterns": {"type": "array", "items": {"type": "string"}, "description": "filename globs, e.g. ['*.step']"},
109
+ "deliver": {"type": "string", "description": "delivery target for results (telegram, discord, slack, log)"},
110
+ "events": {"type": "array", "items": {"type": "string"}, "description": "kind=webhook: event types to accept"},
111
+ "cooldown_s": {"type": "number", "description": "minimum seconds between wakes (default 0)"},
112
+ },
113
+ "required": ["kind", "name"],
114
+ },
115
+ }
116
+
117
+ LIST_SCHEMA = {
118
+ "name": "trigger_list",
119
+ "description": "List all galvanize triggers with their source, wake mode, and settings.",
120
+ "parameters": {"type": "object", "properties": {}},
121
+ }
122
+
123
+ REMOVE_SCHEMA = {
124
+ "name": "trigger_remove",
125
+ "description": "Remove a galvanize trigger (and its Hermes webhook route if it had one).",
126
+ "parameters": {
127
+ "type": "object",
128
+ "properties": {"name": {"type": "string"}},
129
+ "required": ["name"],
130
+ },
131
+ }
132
+
133
+ TEST_SCHEMA = {
134
+ "name": "trigger_test",
135
+ "description": (
136
+ "Inject a synthetic event through a trigger's real dispatch path and report "
137
+ "what happened. ALWAYS run this right after trigger_add so the user sees the "
138
+ "trigger work before relying on it."
139
+ ),
140
+ "parameters": {
141
+ "type": "object",
142
+ "properties": {
143
+ "name": {"type": "string"},
144
+ "payload": {"type": "object", "description": "optional synthetic payload"},
145
+ },
146
+ "required": ["name"],
147
+ },
148
+ }
149
+
150
+ STATUS_SCHEMA = {
151
+ "name": "trigger_status",
152
+ "description": (
153
+ "Health of the trigger system: daemon alive?, Hermes webhook lane enabled and "
154
+ "reachable?, per-trigger last-fire time, fires today, last error. Use to answer "
155
+ "'is my trigger still watching?' honestly instead of guessing."
156
+ ),
157
+ "parameters": {"type": "object", "properties": {}},
158
+ }
159
+
160
+
161
+ # ---------------------------------------------------------------- handlers
162
+
163
+ def _handle_trigger_add(args, **kw):
164
+ r = _run_op(
165
+ "add",
166
+ args.get("kind", ""),
167
+ args.get("target", "") or str(args.get("target_path", "")),
168
+ name=args.get("name", ""),
169
+ wake="hermes",
170
+ deliver=args.get("deliver", "") or "",
171
+ prompt=args.get("prompt", "") or "",
172
+ patterns=args.get("patterns") or None,
173
+ events=args.get("events") or None,
174
+ cooldown_s=float(args.get("cooldown_s", 0) or 0),
175
+ )
176
+ return json.dumps(r, ensure_ascii=False)
177
+
178
+
179
+ def _handle_trigger_list(args, **kw):
180
+ return json.dumps(_run_op("list"), ensure_ascii=False)
181
+
182
+
183
+ def _handle_trigger_remove(args, **kw):
184
+ return json.dumps(_run_op("remove", name=args.get("name", "")), ensure_ascii=False)
185
+
186
+
187
+ def _handle_trigger_test(args, **kw):
188
+ return json.dumps(
189
+ _run_op("test", name=args.get("name", ""), payload=args.get("payload") or None),
190
+ ensure_ascii=False,
191
+ )
192
+
193
+
194
+ def _handle_trigger_status(args, **kw):
195
+ return json.dumps(_run_op("status"), ensure_ascii=False)
196
+
197
+
198
+ # ---------------------------------------------------------------- slash + CLI
199
+
200
+ def _slash_triggers(raw_args: str) -> str:
201
+ """/triggers [list|status|test <name>] — zero-LLM trigger checks."""
202
+ parts = (raw_args or "").split()
203
+ verb = parts[0].lower() if parts else "list"
204
+ try:
205
+ from galvanize import manage
206
+ except ImportError:
207
+ return "galvanize is not installed in this interpreter."
208
+ if verb == "test" and len(parts) > 1:
209
+ r = manage.test_trigger(parts[1])
210
+ return ("✔ " + r.get("detail", "accepted")) if r.get("ok") else ("✖ " + r.get("error", "failed"))
211
+ s = manage.status()
212
+ lines = [f"daemon: {'up' if s['daemon_alive'] else 'DOWN'} "
213
+ f"webhook lane: {'on' if s['webhook_enabled'] else 'off'} "
214
+ f"gateway: {'up' if s['gateway_running'] else 'down'}", ""]
215
+ if not s["triggers"]:
216
+ lines.append("No triggers yet — describe the event you want watched.")
217
+ for row in s["triggers"]:
218
+ mark = "●" if row["watching"] else ("○" if row["enabled"] else "–")
219
+ err = f" ERR: {str(row['last_error'])[:40]}" if row["last_error"] else ""
220
+ lines.append(f"{mark} {row['name']} {row['source']}→{row['wake']} "
221
+ f"last: {row['last_fire']} today: {row['fires_today']}{err}")
222
+ for n in s["notes"]:
223
+ lines.append(f"! {n}")
224
+ return "\n".join(lines)
225
+
226
+
227
+ def _cli_setup_triggers(subparser) -> None:
228
+ subparser.add_argument("verb", nargs="?", default="status",
229
+ choices=["status", "list", "test", "doctor"],
230
+ help="what to show (default: status)")
231
+ subparser.add_argument("name", nargs="?", default="",
232
+ help="trigger name (for test)")
233
+
234
+
235
+ def _cli_handle_triggers(args) -> None:
236
+ """`hermes triggers ...` — thin wrapper over manage ops."""
237
+ from galvanize import manage
238
+ verb = getattr(args, "verb", "status")
239
+ if verb == "test":
240
+ r = manage.test_trigger(getattr(args, "name", "") or "")
241
+ print(r.get("detail") or r.get("error") or "done") if (r.get("ok") or r.get("error")) else print("done")
242
+ for ln in r.get("lines", []):
243
+ print(" ", ln)
244
+ return
245
+ if verb == "doctor":
246
+ r = manage.doctor()
247
+ for c in r["checks"]:
248
+ print(("✔ " if c["ok"] else "✖ ") + f"{c['name']:<28} {c['note']}")
249
+ return
250
+ s = manage.status() if verb in ("status", "list") else manage.status()
251
+ if not s["triggers"]:
252
+ print("No triggers yet — ask the agent to create one, or: galvanize add folder <path>")
253
+ for row in s["triggers"]:
254
+ print(f"{'●' if row['watching'] else '–'} {row['name']:<24} "
255
+ f"{row['source']:>8} → {row['wake']:<6} last: {row['last_fire']} "
256
+ f"today: {row['fires_today']}")
257
+ for n in s["notes"]:
258
+ print(f"! {n}")
259
+
260
+
261
+ def _galvanize_present() -> bool:
262
+ try:
263
+ import galvanize # noqa: F401
264
+ return True
265
+ except ImportError:
266
+ return bool(shutil.which("galvanize"))
267
+
268
+
269
+ def register(ctx):
270
+ for schema, handler in (
271
+ (ADD_SCHEMA, _handle_trigger_add),
272
+ (LIST_SCHEMA, _handle_trigger_list),
273
+ (REMOVE_SCHEMA, _handle_trigger_remove),
274
+ (TEST_SCHEMA, _handle_trigger_test),
275
+ (STATUS_SCHEMA, _handle_trigger_status),
276
+ ):
277
+ ctx.register_tool(
278
+ name=schema["name"],
279
+ toolset="galvanize",
280
+ schema=schema,
281
+ handler=handler,
282
+ description=schema["description"],
283
+ check_fn=_galvanize_present,
284
+ )
285
+
286
+ # Zero-LLM management surfaces (PLAN item 12b/12c): /triggers slash in
287
+ # chat sessions + `hermes triggers` terminal verb.
288
+ try:
289
+ ctx.register_command(
290
+ name="triggers",
291
+ handler=_slash_triggers,
292
+ description="List galvanize triggers and their health (no agent turn spent)",
293
+ args_hint="[list|status|test <name>]",
294
+ )
295
+ except Exception as e: # older hosts without register_command: tool surface still works
296
+ logger.debug("galvanize: /triggers slash not registered: %s", e)
297
+ try:
298
+ ctx.register_cli_command(
299
+ name="triggers",
300
+ help="show or test galvanize event triggers",
301
+ setup_fn=_cli_setup_triggers,
302
+ handler_fn=_cli_handle_triggers,
303
+ description="Thin wrapper over galvanize manage ops (status/list/test/doctor).",
304
+ )
305
+ except Exception as e:
306
+ logger.debug("galvanize: hermes triggers CLI not registered: %s", e)
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Galvanize — Triggers dashboard tab
3
+ *
4
+ * Job management for event triggers (the cron-page analogue): per-trigger
5
+ * watching state, last fire, fires today, last error, and row actions
6
+ * (test-fire / enable-toggle / remove). MANAGES ONLY — creating triggers
7
+ * stays agent/conversation-first by design.
8
+ *
9
+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ (React +
10
+ * host components + authed fetchJSON). Talks to /api/plugins/galvanize/.
11
+ */
12
+ (function () {
13
+ "use strict";
14
+
15
+ var SDK = window.__HERMES_PLUGIN_SDK__;
16
+ if (!SDK) return;
17
+
18
+ var React = SDK.React;
19
+ var h = React.createElement;
20
+ var useState = SDK.hooks.useState;
21
+ var useEffect = SDK.hooks.useEffect;
22
+ var useCallback = SDK.hooks.useCallback;
23
+ var Badge = SDK.components.Badge;
24
+ var Button = SDK.components.Button;
25
+ var Card = SDK.components.Card;
26
+ var CardContent = SDK.components.CardContent;
27
+ var cn = SDK.utils.cn || function () { return Array.prototype.slice.call(arguments).filter(Boolean).join(" "); };
28
+ var timeAgo = SDK.utils.timeAgo;
29
+
30
+ var API = "/api/plugins/galvanize";
31
+ var POLL_MS = 5000;
32
+
33
+ function errText(e) {
34
+ if (!e) return "unknown error";
35
+ if (e.detail && typeof e.detail === "string") return e.detail;
36
+ return String(e.message || e);
37
+ }
38
+
39
+ function HealthChip(props) {
40
+ var ok = props.ok, label = props.label, hint = props.note;
41
+ return h("span", {
42
+ className: cn("inline-flex items-center gap-1.5 text-xs",
43
+ ok ? "text-green-600" : "text-red-600"),
44
+ title: hint || "",
45
+ }, h("span", {
46
+ className: cn("inline-block w-2 h-2 rounded-full",
47
+ ok ? "bg-green-500" : "bg-red-500"),
48
+ }), label);
49
+ }
50
+
51
+ function Row(props) {
52
+ var t = props.trigger;
53
+ var busy = props.busy;
54
+ var onAction = props.onAction;
55
+
56
+ var wakeLabel = t.wake === "hermes" ? "Hermes" : t.wake;
57
+ var srcLabel = { folder: "folder", imap: "email", webhook: "webhook", emit: "emit" }[t.source] || t.source;
58
+ var detail = t.path || t.mailbox || "";
59
+
60
+ return h("tr", { className: "border-b border-border/40" },
61
+ h("td", { className: "py-2 pr-3 align-top" },
62
+ h("div", { className: "flex items-center gap-2" },
63
+ h("span", {
64
+ className: cn("inline-block w-2 h-2 rounded-full shrink-0",
65
+ !t.enabled ? "bg-muted-foreground/40"
66
+ : t.watching ? "bg-green-500" : "bg-amber-500"),
67
+ title: !t.enabled ? "disabled" : t.watching ? "watching" : "not watching",
68
+ }),
69
+ h("span", { className: "font-medium" }, t.name),
70
+ t.description ? h("span", { className: "text-muted-foreground text-xs truncate max-w-52" },
71
+ t.description) : null)),
72
+ h("td", { className: "py-2 pr-3 align-top text-xs" },
73
+ h(Badge, { variant: "outline" }, srcLabel),
74
+ detail ? h("div", { className: "text-muted-foreground mt-1 truncate max-w-64", title: detail },
75
+ detail) : null),
76
+ h("td", { className: "py-2 pr-3 align-top text-xs text-muted-foreground" }, wakeLabel),
77
+ h("td", { className: "py-2 pr-3 align-top text-xs" },
78
+ t.last_fire === "never" || !t.last_fire
79
+ ? h("span", { className: "text-muted-foreground" }, "never") : t.last_fire),
80
+ h("td", { className: "py-2 pr-3 align-top text-xs tabular-nums" }, String(t.fires_today || 0)),
81
+ h("td", { className: "py-2 pr-3 align-top text-xs" },
82
+ t.last_error
83
+ ? h("span", { className: "text-red-600", title: t.last_error },
84
+ String(t.last_error).slice(0, 60))
85
+ : h("span", { className: "text-muted-foreground" }, "—")),
86
+ h("td", { className: "py-2 align-top text-right whitespace-nowrap" },
87
+ h(Button, {
88
+ size: "sm", variant: "ghost", disabled: busy,
89
+ title: "Send a synthetic test event through the real dispatch path",
90
+ onClick: function () { onAction("test", t); },
91
+ }, "Test"),
92
+ h(Button, {
93
+ size: "sm", variant: "ghost", disabled: busy,
94
+ onClick: function () { onAction("toggle", t); },
95
+ }, t.enabled ? "Disable" : "Enable"),
96
+ h(Button, {
97
+ size: "sm", variant: "ghost", disabled: busy,
98
+ className: "text-red-600",
99
+ onClick: function () { onAction("remove", t); },
100
+ }, "Remove")));
101
+ }
102
+
103
+ function TriggersPage() {
104
+ var state = useState(null); var data = state[0], setData = state[1];
105
+ var busyS = useState(""); var busy = busyS[0], setBusy = busyS[1];
106
+ var msgS = useState(""); var msg = msgS[0], setMsg = msgS[1];
107
+ var errS = useState(""); var err = errS[0], setErr = errS[1];
108
+
109
+ var refresh = useCallback(function () {
110
+ SDK.fetchJSON(API + "/status").then(function (r) {
111
+ setData(r);
112
+ setErr("");
113
+ }).catch(function (e) { setErr(errText(e)); });
114
+ }, []);
115
+
116
+ useEffect(function () {
117
+ refresh();
118
+ var iv = setInterval(refresh, POLL_MS);
119
+ return function () { clearInterval(iv); };
120
+ }, [refresh]);
121
+
122
+ var act = useCallback(function (kind, t) {
123
+ if (kind === "remove" &&
124
+ !window.confirm("Remove trigger '" + t.name +
125
+ "'?\nThis also removes its Hermes webhook route if it had one.")) {
126
+ return;
127
+ }
128
+ setBusy(t.name); setMsg(""); setErr("");
129
+ var url = API + "/" + (kind === "toggle" ? "toggle" : kind);
130
+ var body = kind === "toggle"
131
+ ? { name: t.name, enabled: !t.enabled }
132
+ : { name: t.name };
133
+ SDK.fetchJSON(url, {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify(body),
137
+ }).then(function (r) {
138
+ if (kind === "test") {
139
+ setMsg(r && r.ok
140
+ ? "Test event fired: " + (r.detail || "accepted") + " \u2014 a fresh agent session is starting."
141
+ : "Test failed: " + (r && (r.error || r.detail) || "unknown"));
142
+ }
143
+ refresh();
144
+ }).catch(function (e) { setErr(errText(e)); });
145
+ // clear busy after the refresh settles (fetchJSON has no finally chain guarantee across hosts)
146
+ setTimeout(function () { setBusy(""); refresh(); }, 1200);
147
+ }, [refresh]);
148
+
149
+ if (!data) {
150
+ return h("div", { className: "p-6 text-sm text-muted-foreground" }, "Loading triggers…");
151
+ }
152
+
153
+ var notes = data.notes || [];
154
+ var triggers = data.triggers || [];
155
+
156
+ // --- header row ---
157
+ var header = h("div", { className: "flex items-center justify-between flex-wrap gap-2" },
158
+ h("div", null,
159
+ h("h2", { className: "text-lg font-semibold" }, "Triggers"),
160
+ h("div", { className: "text-xs text-muted-foreground" },
161
+ "Events that wake agent sessions. To create one, just tell the agent:",
162
+ " \u201cwatch my downloads folder and wake me when a CAD file lands.\u201d")),
163
+ h("div", { className: "flex items-center gap-3" },
164
+ h(HealthChip, { ok: !!data.daemon_alive, label: "daemon" }),
165
+ h(HealthChip, { ok: !!data.webhook_enabled, label: "webhook lane" }),
166
+ h(HealthChip, { ok: !!data.gateway_running, label: "gateway" }),
167
+ h(Button, { size: "sm", variant: "ghost", onClick: refresh }, "Refresh")));
168
+
169
+ // --- messages / notes card ---
170
+ var msgCard = null;
171
+ if (err || msg || notes.length) {
172
+ msgCard = h(Card, null,
173
+ h(CardContent, { className: "py-3 text-sm space-y-1" },
174
+ err ? h("div", { className: "text-red-600" }, err) : null,
175
+ msg ? h("div", { className: "text-green-600" }, msg) : null,
176
+ notes.map(function (n, i) {
177
+ return h("div", { key: i, className: "text-amber-600 text-xs" }, "! " + n);
178
+ })));
179
+ }
180
+
181
+ // --- table or empty state ---
182
+ var body;
183
+ if (triggers.length === 0) {
184
+ body = h("div", {
185
+ className: "border border-dashed border-border rounded-lg p-8 text-center text-sm text-muted-foreground space-y-2",
186
+ },
187
+ h("div", null, "No triggers yet."),
188
+ h("div", null, "Ask your agent:",
189
+ h("code", { className: "text-xs" }, " wake me when resumes land in my inbox "),
190
+ " or run ",
191
+ h("code", { className: "text-xs" }, "galvanize add folder ~/watch --wake hermes"), "."));
192
+ } else {
193
+ var headRow = h("tr", { className: "text-left text-xs text-muted-foreground" },
194
+ h("th", { className: "py-2 pr-3 font-medium" }, "Trigger"),
195
+ h("th", { className: "py-2 pr-3 font-medium" }, "Source"),
196
+ h("th", { className: "py-2 pr-3 font-medium" }, "Wake"),
197
+ h("th", { className: "py-2 pr-3 font-medium" }, "Last fire"),
198
+ h("th", { className: "py-2 pr-3 font-medium" }, "Today"),
199
+ h("th", { className: "py-2 pr-3 font-medium" }, "Last error"),
200
+ h("th", { className: "py-2 font-medium" }, ""));
201
+ var rows = triggers.map(function (t) {
202
+ return h(Row, { key: t.name, trigger: t, busy: busy === t.name, onAction: act });
203
+ });
204
+ body = h("div", { className: "overflow-x-auto" },
205
+ h("table", { className: "w-full text-sm" },
206
+ h("thead", null, headRow),
207
+ h("tbody", null, rows)));
208
+ }
209
+
210
+ var footer = h("div", { className: "text-xs text-muted-foreground" },
211
+ "Managed by galvanize \u00b7 status refreshes every " + (POLL_MS / 1000) + "s");
212
+
213
+ return h("div", { className: "p-4 space-y-4" }, header, msgCard, body, footer);
214
+ }
215
+
216
+ if (window.__HERMES_PLUGINS__ && typeof window.__HERMES_PLUGINS__.register === "function") {
217
+ window.__HERMES_PLUGINS__.register("galvanize", TriggersPage);
218
+ }
219
+ })();
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "galvanize",
3
+ "label": "Triggers",
4
+ "description": "Event triggers that wake agent sessions — watch status, last fire, test-fire, enable/disable, remove",
5
+ "icon": "Zap",
6
+ "version": "0.2.0",
7
+ "tab": {
8
+ "path": "/triggers",
9
+ "position": "after:cron"
10
+ },
11
+ "entry": "dist/index.js",
12
+ "api": "plugin_api.py"
13
+ }
@@ -0,0 +1,66 @@
1
+ """Galvanize dashboard plugin — backend API routes.
2
+
3
+ Mounted at /api/plugins/galvanize/ by the dashboard plugin system
4
+ (auth: the dashboard's session-token middleware covers /api/plugins/*).
5
+
6
+ Intentionally thin: every handler calls galvanize.manage directly — the
7
+ same ops the CLI, the agent tools, and the serve API use, so the four
8
+ surfaces cannot drift. This is IN-PROCESS (the dashboard does not go
9
+ through `galvanize serve`; two doors, one lock).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, Optional
15
+
16
+ from fastapi import APIRouter, Body, HTTPException
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ def _manage():
22
+ try:
23
+ from galvanize import manage
24
+ return manage
25
+ except ImportError:
26
+ raise HTTPException(
27
+ status_code=503,
28
+ detail="galvanize is not installed in the Hermes interpreter: pip install galvanize",
29
+ )
30
+
31
+
32
+ @router.get("/status")
33
+ def status() -> Dict[str, Any]:
34
+ return _manage().status()
35
+
36
+
37
+ @router.get("/doctor")
38
+ def doctor() -> Dict[str, Any]:
39
+ return _manage().doctor()
40
+
41
+
42
+ @router.post("/test")
43
+ def test(body: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
44
+ name = str(body.get("name", "")).strip()
45
+ if not name:
46
+ raise HTTPException(status_code=400, detail="name required")
47
+ return _manage().test_trigger(name, body.get("payload"))
48
+
49
+
50
+ @router.post("/toggle")
51
+ def toggle(body: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
52
+ from galvanize.config import set_enabled
53
+ name = str(body.get("name", "")).strip()
54
+ enabled = bool(body.get("enabled", True))
55
+ if not set_enabled(name, enabled):
56
+ raise HTTPException(status_code=404, detail=f"no trigger '{name}'")
57
+ return {"ok": True, "name": name, "enabled": enabled}
58
+
59
+
60
+ @router.post("/remove")
61
+ def remove(body: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
62
+ name = str(body.get("name", "")).strip()
63
+ r = _manage().remove_trigger_op(name)
64
+ if not r.get("ok"):
65
+ raise HTTPException(status_code=400, detail=r.get("error", "remove failed"))
66
+ return r
@@ -0,0 +1,11 @@
1
+ name: galvanize
2
+ version: "0.2.0"
3
+ description: "Event-driven triggers for Hermes: wake a fresh agent session when files land, webhooks fire, or events are emitted. Gives the agent trigger_add/list/remove/test/status tools so 'trigger me when X' never silently becomes a cron poller."
4
+ author: Jarvis Lai
5
+ kind: standalone
6
+ provides_tools:
7
+ - trigger_add
8
+ - trigger_list
9
+ - trigger_remove
10
+ - trigger_test
11
+ - trigger_status