pushary 1.9.65 → 1.9.67
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.
- package/CHANGELOG.md +18 -0
- package/data/SKILL.md +1 -1
- package/data/cursor-plugin/skills/pushary/SKILL.md +1 -1
- package/data/vscode-plugin/skills/pushary/SKILL.md +1 -1
- package/dist/{api-32B3XLJR.js → api-3WIPD5GC.js} +12 -10
- package/dist/bin/pushary-bell-hook.js +25 -27
- package/dist/bin/pushary-bell.js +10 -10
- package/dist/bin/pushary-claude.js +26 -28
- package/dist/bin/pushary-clean.js +74 -77
- package/dist/bin/pushary-codex-bridge.js +21 -22
- package/dist/bin/pushary-codex-hook.js +30 -32
- package/dist/bin/pushary-codex-session.js +8 -8
- package/dist/bin/pushary-codex.js +13 -13
- package/dist/bin/pushary-connect.js +28 -28
- package/dist/bin/pushary-cowork.js +5 -5
- package/dist/bin/pushary-daemon.js +40 -41
- package/dist/bin/pushary-disconnect.js +25 -26
- package/dist/bin/pushary-doctor.js +118 -76
- package/dist/bin/pushary-elicitation-hook.js +25 -27
- package/dist/bin/pushary-gemini-bridge.js +15 -16
- package/dist/bin/pushary-gemini-hook.js +27 -29
- package/dist/bin/pushary-hook.js +29 -31
- package/dist/bin/pushary-login.js +20 -19
- package/dist/bin/pushary-logout.js +26 -28
- package/dist/bin/pushary-mcp.js +11 -11
- package/dist/bin/pushary-mode.js +16 -15
- package/dist/bin/pushary-notification-hook.js +24 -26
- package/dist/bin/pushary-opencode-hook.js +26 -28
- package/dist/bin/pushary-permission-denied-hook.js +29 -31
- package/dist/bin/pushary-permission-hook.js +32 -31
- package/dist/bin/pushary-post-hook.js +24 -26
- package/dist/bin/pushary-prompt-hook.js +24 -26
- package/dist/bin/pushary-session-end-hook.js +26 -28
- package/dist/bin/pushary-session-start-hook.js +34 -35
- package/dist/bin/pushary-setup.js +111 -99
- package/dist/bin/pushary-stats.js +5 -5
- package/dist/bin/pushary-status.js +37 -36
- package/dist/bin/pushary-stop-hook.js +24 -26
- package/dist/bin/pushary-stopfailure-hook.js +24 -26
- package/dist/bin/pushary-suggestions.js +15 -14
- package/dist/bin/pushary-transcript-register.js +16 -18
- package/dist/bin/pushary-transcripts.js +18 -17
- package/dist/bin/pushary-upgrade.js +45 -39
- package/dist/bin/pushary-wait.js +26 -20
- package/dist/bin/pushary.js +7 -7
- package/dist/{chunk-Y64JGYTB.js → chunk-32OSSYGZ.js} +1 -1
- package/dist/{chunk-UHS5MMAI.js → chunk-3THDNNVL.js} +3 -3
- package/dist/{chunk-OA6EADFT.js → chunk-564IPPBL.js} +3 -3
- package/dist/chunk-5MOJCOCF.js +25 -0
- package/dist/{chunk-RRHHWYPX.js → chunk-6H4E5UBA.js} +6 -6
- package/dist/{chunk-UM2X7HL6.js → chunk-6LXXKMV6.js} +42 -16
- package/dist/chunk-76SWHCAR.js +299 -0
- package/dist/{chunk-AN7C3CHH.js → chunk-77L2TNJW.js} +1 -1
- package/dist/{chunk-XJIIU36L.js → chunk-7YJSDXLM.js} +1 -1
- package/dist/{chunk-XZCJC3QO.js → chunk-B3V2WOJX.js} +285 -50
- package/dist/{chunk-RABLE5R6.js → chunk-BBFGDYYI.js} +1 -1
- package/dist/{chunk-GTBWWCCM.js → chunk-BFF3ABFV.js} +115 -9
- package/dist/{chunk-NOH3EJFH.js → chunk-C4EDAHXI.js} +1 -1
- package/dist/{chunk-ITYKN2K3.js → chunk-CHYBOTQN.js} +2 -2
- package/dist/{chunk-FFSIYBBU.js → chunk-CJZLIAJF.js} +27 -10
- package/dist/{chunk-UMTMFDL2.js → chunk-CPLQ5OHX.js} +1 -1
- package/dist/{chunk-DTVHZDYM.js → chunk-D3U6CN7T.js} +26 -12
- package/dist/{chunk-ICFHMJTW.js → chunk-D7Z4USSB.js} +27 -8
- package/dist/{chunk-DXMUJTYF.js → chunk-DP6LU6BB.js} +2 -2
- package/dist/{chunk-GA257T2E.js → chunk-DXCHIKAN.js} +8 -8
- package/dist/{chunk-PP7FC3Y7.js → chunk-EQR6JSFE.js} +9 -7
- package/dist/{chunk-Z3M627TQ.js → chunk-FONWVVDD.js} +1 -1
- package/dist/{chunk-3KXBRSL3.js → chunk-GMHQYX3D.js} +1 -1
- package/dist/{chunk-WY6YAMZW.js → chunk-ICQCQAUO.js} +5 -4
- package/dist/{chunk-CZ65D6Q5.js → chunk-IPG3RS23.js} +2 -2
- package/dist/{chunk-JCWHOB36.js → chunk-IRV2MNMA.js} +53 -3
- package/dist/{chunk-HIYLAC3C.js → chunk-IVB5O6NY.js} +3 -3
- package/dist/{chunk-DM24VZOI.js → chunk-IXTVUINH.js} +3 -3
- package/dist/{chunk-BEQ3MEE3.js → chunk-JBRMIADM.js} +2 -2
- package/dist/{chunk-CU2PXLZ6.js → chunk-JHB5SR5B.js} +4 -4
- package/dist/{chunk-ED2SWTPC.js → chunk-JOBSKQ2Z.js} +4 -4
- package/dist/{chunk-W5L2ZLHE.js → chunk-JOD2325S.js} +3 -3
- package/dist/{chunk-CSKABVSH.js → chunk-JSPCGPSH.js} +3 -3
- package/dist/{chunk-R4FL5JHK.js → chunk-KE4TSJUD.js} +4 -4
- package/dist/{chunk-3LF7T3NW.js → chunk-KIXRHNCW.js} +6 -6
- package/dist/{chunk-BKCI53W6.js → chunk-KNUYUQBP.js} +4 -4
- package/dist/{chunk-4LUWNBGD.js → chunk-KTLE7NIK.js} +23 -8
- package/dist/{chunk-ADXHPGBZ.js → chunk-LJHDUKBB.js} +6 -6
- package/dist/{chunk-U2P2LQ5B.js → chunk-LS42NV6E.js} +15 -6
- package/dist/{chunk-QNZ6G3LY.js → chunk-LYTWK5TF.js} +5 -5
- package/dist/{chunk-2VADJ6JH.js → chunk-METUT7V4.js} +2 -2
- package/dist/{chunk-P5HZEGT7.js → chunk-NSS2AIGI.js} +6 -6
- package/dist/{chunk-QSW3HAKI.js → chunk-NWN6GGJL.js} +61 -2
- package/dist/{chunk-7ILQX47W.js → chunk-P4RXSZDT.js} +3 -3
- package/dist/{chunk-FWS2KLV2.js → chunk-PIBWX3EF.js} +2 -2
- package/dist/{chunk-F56GTJFG.js → chunk-PO2P3XDC.js} +5 -5
- package/dist/{chunk-EEVE7H5Z.js → chunk-R2BZH2NK.js} +4 -4
- package/dist/{chunk-I2UBF2SS.js → chunk-RO2B4YBF.js} +1 -1
- package/dist/{chunk-MNKIR2ZB.js → chunk-RQOT6QL3.js} +67 -6
- package/dist/{chunk-Q2BD4IBX.js → chunk-SB6TYV4M.js} +11 -13
- package/dist/{chunk-ATPJGHUP.js → chunk-TI4JYPGQ.js} +7 -3
- package/dist/{chunk-LMC5UPL7.js → chunk-UC2J5RR4.js} +1 -1
- package/dist/{chunk-IZVEBBDH.js → chunk-UQIPWXBL.js} +3 -3
- package/dist/{chunk-VHKK3US3.js → chunk-UXN73YAE.js} +3 -3
- package/dist/{chunk-WOHP7EL6.js → chunk-VEYFXRZ6.js} +7 -6
- package/dist/{chunk-2Z7I7DHT.js → chunk-VHMZPN3N.js} +1 -1
- package/dist/{chunk-HVOQUPSO.js → chunk-VKZ3RJ5U.js} +1 -1
- package/dist/chunk-VVIQWGS7.js +2298 -0
- package/dist/{chunk-SCMEVDAR.js → chunk-WJLHZ6E6.js} +3 -3
- package/dist/{chunk-BAGBCSXA.js → chunk-X2MTVD4W.js} +1 -1
- package/dist/{chunk-3UNAPXED.js → chunk-XFDWLDHG.js} +9 -9
- package/dist/{chunk-6CZX6YOE.js → chunk-XNUWKKGS.js} +3 -3
- package/dist/{chunk-NUEPJ5W6.js → chunk-XZKSUJ55.js} +3 -5
- package/dist/{chunk-HDGB6SQ4.js → chunk-Y4MRHAE5.js} +2 -2
- package/dist/{chunk-SZBMTOUZ.js → chunk-Y6ZN454E.js} +1 -1
- package/dist/{chunk-TK4JU2F7.js → chunk-YSD4SMHE.js} +1 -1
- package/dist/{chunk-SROWUG5W.js → chunk-ZK5WYAMI.js} +1 -1
- package/dist/{chunk-JFKMPLNF.js → chunk-ZPIYJLQ3.js} +2 -2
- package/dist/{codex-app-cards-switch-HK4RMOBI.js → codex-app-cards-switch-25TDTMUZ.js} +11 -11
- package/dist/{codex-desktop-launch-OCO6N7SA.js → codex-desktop-launch-JDKUCD2Y.js} +1 -1
- package/dist/{codex-session-launch-ETZQTVFS.js → codex-session-launch-6N35AAEO.js} +18 -19
- package/dist/{config-YY54G3CC.js → config-OBGYURFX.js} +3 -3
- package/dist/{global-upgrade-5DN5G3ZQ.js → global-upgrade-2QNQD5OM.js} +10 -10
- package/dist/{identity-EGL6HAWG.js → identity-CUPX2U7Q.js} +4 -4
- package/dist/{live-capabilities-WU3FYJJG.js → live-capabilities-CUEIWMSM.js} +5 -5
- package/dist/{paths-ZG6KDM6Y.js → paths-BHLBZ6FY.js} +7 -3
- package/dist/{reapply-AQLNCCTE.js → reapply-4GXLWZ5M.js} +27 -27
- package/dist/src/index.js +30 -20
- package/dist/{upgrade-lock-BIRXIKHJ.js → upgrade-lock-RDKTAHWR.js} +4 -4
- package/dist/{usage-43C2DHDU.js → usage-KX2HN47E.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-ANP3DIST.js +0 -118
- package/dist/chunk-ONBL2GCU.js +0 -615
- package/dist/chunk-TJUJAK5L.js +0 -62
|
@@ -0,0 +1,2298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
needsShell
|
|
3
|
+
} from "./chunk-XHKBHWLX.js";
|
|
4
|
+
import {
|
|
5
|
+
hermesAppWiringRecord,
|
|
6
|
+
hermesConfigReceipt,
|
|
7
|
+
hermesSetupReceipt
|
|
8
|
+
} from "./chunk-TI4JYPGQ.js";
|
|
9
|
+
import {
|
|
10
|
+
readJsonSafe,
|
|
11
|
+
resolvePathBinary,
|
|
12
|
+
writeJsonAtomic
|
|
13
|
+
} from "./chunk-WJLHZ6E6.js";
|
|
14
|
+
import {
|
|
15
|
+
isNewerVersion,
|
|
16
|
+
isReleaseVersion
|
|
17
|
+
} from "./chunk-QTOLNRET.js";
|
|
18
|
+
import {
|
|
19
|
+
isRecord
|
|
20
|
+
} from "./chunk-RQOT6QL3.js";
|
|
21
|
+
|
|
22
|
+
// src/hermes/plugin-payload.ts
|
|
23
|
+
import { createHash } from "crypto";
|
|
24
|
+
|
|
25
|
+
// src/hermes/plugin-files.generated.ts
|
|
26
|
+
var HERMES_PLUGIN_FILES = [
|
|
27
|
+
{ path: "__init__.py", contents: `import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import threading
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from . import api, approval, host, identity, installation, schemas, tools
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
MAX_ERROR_NOTIFICATIONS = 3
|
|
38
|
+
SYSTEM_PROMPT_SECTION_ID = "pushary.reach-the-human"
|
|
39
|
+
HERMES_QUESTION_TOOL = "clarify"
|
|
40
|
+
QUESTION_ROUTING_PROMPT = (
|
|
41
|
+
" Ask the operator questions with pushary_ask, not clarify: clarify is routed to Pushary."
|
|
42
|
+
)
|
|
43
|
+
QUESTION_TOOL_REDIRECT = (
|
|
44
|
+
"Ask this with pushary_ask instead. Pushary routes Hermes questions to the "
|
|
45
|
+
"operator's phone and Mac, so they can answer when nobody is at this terminal."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
SYSTEM_PROMPT_SECTION = (
|
|
49
|
+
"Pushary reaches the operator when they are away. Honor authorization already "
|
|
50
|
+
"granted in this session. Call pushary_ask only for an unresolved decision or "
|
|
51
|
+
"an action outside that authority; never bypass an enforced host gate. Use "
|
|
52
|
+
"pushary_propose_scope only for an unresolved or requested boundary; only file "
|
|
53
|
+
"paths are enforced, so a boundary that is not a path goes in promises, and an "
|
|
54
|
+
"empty enforces means nothing in it is checked automatically. "
|
|
55
|
+
"Follow answered, status and handoffAction (or nextAction); silence is not "
|
|
56
|
+
"consent, and choice/text answers do not authorize a separate action. "
|
|
57
|
+
"Call pushary_notify with context_type task_complete or error for meaningful "
|
|
58
|
+
"unattended results, not every step. Batch related questions. Delivery follows "
|
|
59
|
+
"the user's policy and presence; do not duplicate a live question in chat. "
|
|
60
|
+
"Confirm notifications can offer lock-screen actions; choices and text open "
|
|
61
|
+
"the app. Personal tools reach the operator; Partner tools use external_id "
|
|
62
|
+
"to reach an enrolled customer. For a personal reminder later, use pushary_remind; "
|
|
63
|
+
"it runs on Pushary after Hermes exits. Use Hermes cron for scheduled agent work."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
TOOL_REGISTRATIONS = (
|
|
67
|
+
("pushary_remind", schemas.PUSHARY_REMIND, "pushary_remind",
|
|
68
|
+
"Schedule, list or cancel personal reminders on Pushary"),
|
|
69
|
+
("pushary_notify", schemas.PUSHARY_NOTIFY, "pushary_notify",
|
|
70
|
+
"Send push notification to user's phone"),
|
|
71
|
+
("pushary_ask", schemas.PUSHARY_ASK, "pushary_ask",
|
|
72
|
+
"Ask user a question via push notification"),
|
|
73
|
+
("pushary_wait", schemas.PUSHARY_WAIT, "pushary_wait",
|
|
74
|
+
"Wait for user's answer to a push question"),
|
|
75
|
+
("pushary_cancel", schemas.PUSHARY_CANCEL, "pushary_cancel",
|
|
76
|
+
"Cancel a pending push question"),
|
|
77
|
+
("pushary_propose_scope", schemas.PUSHARY_PROPOSE_SCOPE, "pushary_propose_scope",
|
|
78
|
+
"Agree what this run may touch before starting it"),
|
|
79
|
+
("pushary_enroll", schemas.PUSHARY_ENROLL, "pushary_enroll",
|
|
80
|
+
"Connect one of your own end-users' phones for approvals (Partner plan)"),
|
|
81
|
+
("pushary_ask_end_user", schemas.PUSHARY_ASK_END_USER, "pushary_ask_end_user",
|
|
82
|
+
"Ask one of your own end-users and block on a fail-closed answer (Partner plan)"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_session_totals = {"tools": 0, "errors": 0, "notified": 0}
|
|
86
|
+
_last_turn = {"completed": False, "interrupted": False}
|
|
87
|
+
_plugin_activity = {"attempted": False}
|
|
88
|
+
_plugin_activity_lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def register(ctx):
|
|
92
|
+
for name, schema, handler_name, description in TOOL_REGISTRATIONS:
|
|
93
|
+
ctx.register_tool(
|
|
94
|
+
name=name,
|
|
95
|
+
toolset="pushary",
|
|
96
|
+
schema=schema,
|
|
97
|
+
handler=getattr(tools, handler_name),
|
|
98
|
+
description=description,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
hooks = {
|
|
102
|
+
"pre_llm_call": _on_pre_llm_call,
|
|
103
|
+
"pre_tool_call": _on_pre_tool_call,
|
|
104
|
+
"post_tool_call": _on_post_tool_call,
|
|
105
|
+
"on_session_start": _on_session_start,
|
|
106
|
+
"on_session_end": _on_session_end,
|
|
107
|
+
"on_session_finalize": _on_session_finalize,
|
|
108
|
+
}
|
|
109
|
+
for hook_name, callback in hooks.items():
|
|
110
|
+
ctx.register_hook(hook_name, callback)
|
|
111
|
+
|
|
112
|
+
transport = _register_approval_transport(ctx)
|
|
113
|
+
_register_system_prompt_section(ctx)
|
|
114
|
+
|
|
115
|
+
skills_dir = Path(__file__).parent / "skills"
|
|
116
|
+
if skills_dir.is_dir():
|
|
117
|
+
for child in sorted(skills_dir.iterdir()):
|
|
118
|
+
skill_md = child / "SKILL.md"
|
|
119
|
+
if child.is_dir() and skill_md.exists():
|
|
120
|
+
ctx.register_skill(child.name, skill_md)
|
|
121
|
+
|
|
122
|
+
installation.record_plugin_load(transport)
|
|
123
|
+
|
|
124
|
+
logger.info(
|
|
125
|
+
"[pushary] Plugin registered: %d tools, %d hooks, approval transport %s",
|
|
126
|
+
len(TOOL_REGISTRATIONS),
|
|
127
|
+
len(hooks),
|
|
128
|
+
"registered" if transport else "unavailable",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _register_approval_transport(ctx):
|
|
133
|
+
try:
|
|
134
|
+
ctx.register_approval_transport(host.TRANSPORT_NAME, approval.present)
|
|
135
|
+
return True
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.debug("[pushary] approval transport unavailable: %s", exc)
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def system_prompt_section():
|
|
142
|
+
if installation.routes_questions():
|
|
143
|
+
return SYSTEM_PROMPT_SECTION + QUESTION_ROUTING_PROMPT
|
|
144
|
+
return SYSTEM_PROMPT_SECTION
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _register_system_prompt_section(ctx):
|
|
148
|
+
try:
|
|
149
|
+
ctx.register_system_prompt_section(SYSTEM_PROMPT_SECTION_ID, system_prompt_section())
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.debug("[pushary] system prompt section unavailable: %s", exc)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _gated_tools():
|
|
155
|
+
raw = os.environ.get("PUSHARY_GATE_TOOLS", "")
|
|
156
|
+
return {name.strip().lower() for name in raw.split(",") if name.strip()}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _egress_redactor():
|
|
160
|
+
try:
|
|
161
|
+
from agent.redact import redact_for_egress
|
|
162
|
+
except ImportError:
|
|
163
|
+
return None
|
|
164
|
+
return redact_for_egress
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _redact_outbound_text(text: str) -> str:
|
|
168
|
+
try:
|
|
169
|
+
from agent.redact import redact_sensitive_text
|
|
170
|
+
|
|
171
|
+
redacted = redact_sensitive_text(text, force=True, redact_url_credentials=True)
|
|
172
|
+
redact_for_egress = _egress_redactor()
|
|
173
|
+
return redact_for_egress(redacted) if redact_for_egress else redacted
|
|
174
|
+
except Exception:
|
|
175
|
+
logger.warning("[pushary] host redaction unavailable; omitting outbound detail")
|
|
176
|
+
return "[redaction unavailable]"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _summarize_args(args):
|
|
180
|
+
if not isinstance(args, dict) or not args:
|
|
181
|
+
return None
|
|
182
|
+
parts = []
|
|
183
|
+
for key, value in args.items():
|
|
184
|
+
rendered = _redact_outbound_text(f"{key}: {value}")
|
|
185
|
+
if len(rendered) > 200:
|
|
186
|
+
rendered = rendered[:200] + "\u2026"
|
|
187
|
+
parts.append(rendered)
|
|
188
|
+
return "\\n".join(parts)[:500]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _arg_target(args):
|
|
192
|
+
if not isinstance(args, dict):
|
|
193
|
+
return None
|
|
194
|
+
for key in ("command", "path", "file_path", "url", "query"):
|
|
195
|
+
value = args.get(key)
|
|
196
|
+
if value:
|
|
197
|
+
return " ".join(_redact_outbound_text(str(value)).split()[:2])[:80]
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_APPROVE_VALUES = {"yes", "y", "true", "approve", "approved", "allow", "ok"}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _block_unapproved(tool_name):
|
|
205
|
+
return {
|
|
206
|
+
"action": "block",
|
|
207
|
+
"message": f"Approval for '{tool_name}' was not obtained. The tool was blocked.",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _escalate_to_host_gate(tool_name, args):
|
|
212
|
+
summary = _summarize_args(args)
|
|
213
|
+
detail = f"\\n{summary}" if summary else ""
|
|
214
|
+
return {
|
|
215
|
+
"action": "approve",
|
|
216
|
+
"message": f"Run {tool_name}?{detail}"[:500],
|
|
217
|
+
"rule_key": tool_name,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _requested_gate_seconds():
|
|
222
|
+
try:
|
|
223
|
+
return int(os.environ.get("PUSHARY_GATE_TIMEOUT_MS", "55000")) / 1000
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
return host.GATE_MAX_SECONDS
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _block_undeliverable(tool_name):
|
|
229
|
+
return {
|
|
230
|
+
"action": "block",
|
|
231
|
+
"message": (
|
|
232
|
+
f"'{tool_name}' is gated by Pushary but no phone, browser, or Slack "
|
|
233
|
+
"channel is connected to approve on, so nobody could be asked. Ask in "
|
|
234
|
+
"this session instead, or connect a device in the Pushary dashboard."
|
|
235
|
+
),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _redirects_question_tool(tool_name):
|
|
240
|
+
return (
|
|
241
|
+
(tool_name or "").lower() == HERMES_QUESTION_TOOL
|
|
242
|
+
and installation.routes_questions()
|
|
243
|
+
and api.is_configured()
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _on_pre_tool_call(tool_name=None, args=None, session_id=None, **kwargs):
|
|
248
|
+
identity.remember_session(session_id)
|
|
249
|
+
|
|
250
|
+
if _redirects_question_tool(tool_name):
|
|
251
|
+
return {"action": "block", "message": QUESTION_TOOL_REDIRECT}
|
|
252
|
+
|
|
253
|
+
gated = _gated_tools()
|
|
254
|
+
if not gated:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
name = (tool_name or "").lower()
|
|
258
|
+
if not name or name.startswith("pushary"):
|
|
259
|
+
return None
|
|
260
|
+
if name not in gated:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
if host.pushary_is_selected_transport():
|
|
264
|
+
return _escalate_to_host_gate(tool_name, args)
|
|
265
|
+
|
|
266
|
+
if not api.is_configured():
|
|
267
|
+
return _block_unapproved(tool_name)
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
budget_ms = int(host.gate_budget_seconds(_requested_gate_seconds()) * 1000)
|
|
271
|
+
summary = _summarize_args(args)
|
|
272
|
+
result = json.loads(tools.pushary_ask({
|
|
273
|
+
"question": f"Allow Hermes to run \`{tool_name}\`?",
|
|
274
|
+
"type": "confirm",
|
|
275
|
+
"context": summary,
|
|
276
|
+
"agent_name": identity.agent_name(),
|
|
277
|
+
"tool_name": tool_name,
|
|
278
|
+
"tool_target": _arg_target(args),
|
|
279
|
+
"action_body": summary,
|
|
280
|
+
"wait": True,
|
|
281
|
+
"timeout_ms": budget_ms,
|
|
282
|
+
}))
|
|
283
|
+
if "error" in result:
|
|
284
|
+
return _block_unapproved(tool_name)
|
|
285
|
+
|
|
286
|
+
correlation_id = result.get("correlationId")
|
|
287
|
+
if not correlation_id:
|
|
288
|
+
return _block_unapproved(tool_name)
|
|
289
|
+
|
|
290
|
+
answer = result
|
|
291
|
+
if result.get("handoffAction") == "cancel_then_ask_in_current_client":
|
|
292
|
+
cancellation = api.cancel_question(correlation_id)
|
|
293
|
+
if cancellation.get("handoffAction") == "stop":
|
|
294
|
+
return _block_unapproved(tool_name)
|
|
295
|
+
if not cancellation.get("cancelled"):
|
|
296
|
+
answer = api.wait_for_answer(correlation_id, 1000)
|
|
297
|
+
|
|
298
|
+
if answer.get("answered"):
|
|
299
|
+
if str(answer.get("value", "")).strip().lower() in _APPROVE_VALUES:
|
|
300
|
+
return None
|
|
301
|
+
return {"action": "block", "message": f"Denied from Pushary: '{tool_name}' was not approved by the user."}
|
|
302
|
+
|
|
303
|
+
if answer.get("error"):
|
|
304
|
+
return _block_unapproved(tool_name)
|
|
305
|
+
|
|
306
|
+
if not result.get("handoffAction") and not result.get("nextAction"):
|
|
307
|
+
api.cancel_question(correlation_id)
|
|
308
|
+
if result.get("noDevices"):
|
|
309
|
+
return _block_undeliverable(tool_name)
|
|
310
|
+
return _block_unapproved(tool_name)
|
|
311
|
+
except Exception:
|
|
312
|
+
return _block_unapproved(tool_name)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _tool_failure(result, status, error_message):
|
|
316
|
+
if status is not None:
|
|
317
|
+
return (error_message or "tool returned an error") if status == "error" else None
|
|
318
|
+
try:
|
|
319
|
+
parsed = json.loads(result) if isinstance(result, str) else result
|
|
320
|
+
except (json.JSONDecodeError, TypeError):
|
|
321
|
+
return None
|
|
322
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
323
|
+
return str(parsed["error"])
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _on_post_tool_call(tool_name, args, result, duration_ms=0, status=None,
|
|
328
|
+
error_message=None, **kwargs):
|
|
329
|
+
_session_totals["tools"] += 1
|
|
330
|
+
|
|
331
|
+
failure = _tool_failure(result, status, error_message)
|
|
332
|
+
if failure is None:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
_session_totals["errors"] += 1
|
|
336
|
+
if not api.is_configured():
|
|
337
|
+
return
|
|
338
|
+
if _session_totals["notified"] >= MAX_ERROR_NOTIFICATIONS:
|
|
339
|
+
return
|
|
340
|
+
_session_totals["notified"] += 1
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
failure = _redact_outbound_text(str(failure))
|
|
344
|
+
api.send_notification(
|
|
345
|
+
title="Tool error in Hermes",
|
|
346
|
+
body=f"{tool_name} failed: {failure[:150]}",
|
|
347
|
+
agent_name=identity.agent_name(),
|
|
348
|
+
context={
|
|
349
|
+
"type": "error",
|
|
350
|
+
"errorMessage": failure[:500],
|
|
351
|
+
"summary": f"Tool '{tool_name}' returned an error after {duration_ms}ms",
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _on_session_start(session_id=None, **kwargs):
|
|
359
|
+
identity.remember_session(session_id)
|
|
360
|
+
_session_totals.update(tools=0, errors=0, notified=0)
|
|
361
|
+
_last_turn.update(completed=False, interrupted=False)
|
|
362
|
+
_report_session_event("session_start", session_id, can_drain=True)
|
|
363
|
+
_report_plugin_active_once(session_id)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _claim_plugin_active_report():
|
|
367
|
+
with _plugin_activity_lock:
|
|
368
|
+
if _plugin_activity["attempted"]:
|
|
369
|
+
return False
|
|
370
|
+
_plugin_activity["attempted"] = True
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _report_plugin_active_once(session_id):
|
|
375
|
+
if not session_id or installation.follows_catalog_rules() or not api.is_configured():
|
|
376
|
+
return None
|
|
377
|
+
if not _claim_plugin_active_report():
|
|
378
|
+
return None
|
|
379
|
+
report = threading.Thread(target=api.report_plugin_active, args=(session_id,), name="pushary-plugin-active", daemon=True)
|
|
380
|
+
report.start()
|
|
381
|
+
return report
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _on_pre_llm_call(session_id=None, **kwargs):
|
|
385
|
+
identity.remember_session(session_id)
|
|
386
|
+
if not api.is_configured() or not identity.session_id():
|
|
387
|
+
return None
|
|
388
|
+
result = api.agent_event("user_prompt", identity.session_id(), can_drain=True)
|
|
389
|
+
command = result.get("pendingCommand") if isinstance(result, dict) else None
|
|
390
|
+
if not isinstance(command, str) or not command.strip():
|
|
391
|
+
return None
|
|
392
|
+
return {
|
|
393
|
+
"context": (
|
|
394
|
+
"The operator sent this instruction to the current Hermes session "
|
|
395
|
+
f"from Pushary:\\n\\n{command.strip()}"
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _report_session_event(event, session_id, can_drain=False):
|
|
401
|
+
if not api.is_configured() or not session_id:
|
|
402
|
+
return
|
|
403
|
+
try:
|
|
404
|
+
api.agent_event(event, session_id, can_drain=can_drain)
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _on_session_end(session_id=None, completed=False, interrupted=False, **kwargs):
|
|
410
|
+
identity.remember_session(session_id)
|
|
411
|
+
_last_turn.update(completed=bool(completed), interrupted=bool(interrupted))
|
|
412
|
+
_report_session_event("session_end", session_id or identity.session_id())
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _on_session_finalize(session_id=None, **kwargs):
|
|
416
|
+
_report_session_event("session_closed", session_id or identity.session_id())
|
|
417
|
+
tool_count = _session_totals["tools"]
|
|
418
|
+
error_count = _session_totals["errors"]
|
|
419
|
+
_session_totals.update(tools=0, errors=0, notified=0)
|
|
420
|
+
|
|
421
|
+
if not os.environ.get("PUSHARY_AUTO_NOTIFY_SESSION_END"):
|
|
422
|
+
identity.forget_session()
|
|
423
|
+
return
|
|
424
|
+
if not api.is_configured() or not tool_count:
|
|
425
|
+
identity.forget_session()
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
completed = _last_turn["completed"]
|
|
429
|
+
status = "completed" if completed else ("interrupted" if _last_turn["interrupted"] else "ended")
|
|
430
|
+
reference = str(session_id or identity.session_id() or "")
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
api.send_notification(
|
|
434
|
+
title=f"Hermes session {status}",
|
|
435
|
+
body=f"Session used {tool_count} tool calls with {error_count} errors",
|
|
436
|
+
agent_name=identity.agent_name(),
|
|
437
|
+
context={
|
|
438
|
+
"type": "task_complete" if completed else "info",
|
|
439
|
+
"summary": f"Session {reference[:8]} {status}",
|
|
440
|
+
"details": [
|
|
441
|
+
f"{tool_count} tool calls executed",
|
|
442
|
+
f"{error_count} errors encountered",
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
finally:
|
|
449
|
+
identity.forget_session()
|
|
450
|
+
` },
|
|
451
|
+
{ path: "api.py", contents: `import json
|
|
452
|
+
import hashlib
|
|
453
|
+
import os
|
|
454
|
+
import re
|
|
455
|
+
import time
|
|
456
|
+
from uuid import uuid4
|
|
457
|
+
import urllib.request
|
|
458
|
+
import urllib.error
|
|
459
|
+
import urllib.parse
|
|
460
|
+
from pathlib import Path
|
|
461
|
+
|
|
462
|
+
from . import host, identity, installation
|
|
463
|
+
|
|
464
|
+
BASE_URL = os.environ.get("PUSHARY_BASE_URL", "https://pushary.com")
|
|
465
|
+
API_KEY_VARIABLE = "PUSHARY_API_KEY"
|
|
466
|
+
PLUGIN_ACTIVE_EVENT = "agent_hook_active"
|
|
467
|
+
PLUGIN_ACTIVE_TIMEOUT_SECONDS = 3
|
|
468
|
+
SERVER_BASE = f"{BASE_URL}/api/v1/server"
|
|
469
|
+
|
|
470
|
+
PENDING_DIR = Path.home() / ".pushary" / "run" / "hermes-commands"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _pending_path(session_id):
|
|
474
|
+
identity_value = f"{_get_api_key()}:{identity.machine_id()}:{session_id or '_no_session'}"
|
|
475
|
+
name = hashlib.sha256(identity_value.encode("utf-8")).hexdigest()
|
|
476
|
+
return PENDING_DIR / f"{name}.json"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _read_pending(session_id):
|
|
480
|
+
path = _pending_path(session_id)
|
|
481
|
+
try:
|
|
482
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
483
|
+
if not isinstance(value.get("id"), str) or not isinstance(value.get("text"), str):
|
|
484
|
+
return None
|
|
485
|
+
if value.get("expiresAtMs", 0) <= int(time.time() * 1000):
|
|
486
|
+
path.unlink(missing_ok=True)
|
|
487
|
+
return None
|
|
488
|
+
return value
|
|
489
|
+
except Exception:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _write_pending(session_id, value, exclusive=False):
|
|
494
|
+
path = _pending_path(session_id)
|
|
495
|
+
try:
|
|
496
|
+
PENDING_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
497
|
+
if exclusive:
|
|
498
|
+
with path.open("x", encoding="utf-8") as handle:
|
|
499
|
+
json.dump(value, handle)
|
|
500
|
+
else:
|
|
501
|
+
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
|
502
|
+
temporary.write_text(json.dumps(value), encoding="utf-8")
|
|
503
|
+
os.chmod(temporary, 0o600)
|
|
504
|
+
os.replace(temporary, path)
|
|
505
|
+
os.chmod(path, 0o600)
|
|
506
|
+
return True
|
|
507
|
+
except Exception:
|
|
508
|
+
if exclusive:
|
|
509
|
+
return False
|
|
510
|
+
pending = _read_pending(session_id)
|
|
511
|
+
return pending is not None \\
|
|
512
|
+
and pending.get("id") == value.get("id") \\
|
|
513
|
+
and pending.get("delivered") == value.get("delivered")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _remove_pending(session_id, command_id):
|
|
517
|
+
pending = _read_pending(session_id)
|
|
518
|
+
if pending and pending.get("id") == command_id:
|
|
519
|
+
try:
|
|
520
|
+
_pending_path(session_id).unlink(missing_ok=True)
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _ack_command(command_id, session_id):
|
|
526
|
+
ack = urllib.request.Request(
|
|
527
|
+
f"{BASE_URL}/api/agent/command/ack",
|
|
528
|
+
data=json.dumps(_with_identity({
|
|
529
|
+
"commandId": command_id,
|
|
530
|
+
"sessionId": session_id,
|
|
531
|
+
"state": "delivered",
|
|
532
|
+
})).encode("utf-8"),
|
|
533
|
+
headers={
|
|
534
|
+
"Content-Type": "application/json",
|
|
535
|
+
"Authorization": f"Bearer {_get_api_key()}",
|
|
536
|
+
},
|
|
537
|
+
method="POST",
|
|
538
|
+
)
|
|
539
|
+
try:
|
|
540
|
+
with urllib.request.urlopen(ack, timeout=10) as response:
|
|
541
|
+
return 200 <= response.status < 300
|
|
542
|
+
except Exception:
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
# Fail-closed affirmative set for a confirm decision. Anything not in here (a
|
|
546
|
+
# decline, an empty answer, a timeout) is treated as "not approved".
|
|
547
|
+
AFFIRMATIVE = {"yes", "y", "true", "approve", "approved", "allow", "ok", "okay", "confirm", "accept"}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def is_affirmative(value):
|
|
551
|
+
return str(value or "").strip().lower() in AFFIRMATIVE
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _parse_sse(body):
|
|
555
|
+
last = None
|
|
556
|
+
for event in re.split(r"\\r?\\n\\r?\\n", body):
|
|
557
|
+
data = "\\n".join(
|
|
558
|
+
line[5:].lstrip()
|
|
559
|
+
for line in re.split(r"\\r?\\n", event)
|
|
560
|
+
if line.startswith("data:")
|
|
561
|
+
).strip()
|
|
562
|
+
if not data:
|
|
563
|
+
continue
|
|
564
|
+
try:
|
|
565
|
+
last = json.loads(data)
|
|
566
|
+
except json.JSONDecodeError:
|
|
567
|
+
continue
|
|
568
|
+
return last
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def is_configured():
|
|
572
|
+
return bool(_configured_key())
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _configured_key():
|
|
576
|
+
key = host.profile_secret(API_KEY_VARIABLE)
|
|
577
|
+
if key or installation.follows_catalog_rules():
|
|
578
|
+
return key
|
|
579
|
+
return _key_in_pushary_config()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _key_in_pushary_config():
|
|
583
|
+
path = Path(os.environ.get("PUSHARY_CONFIG_FILE", "")).expanduser() \\
|
|
584
|
+
if os.environ.get("PUSHARY_CONFIG_FILE") else Path.home() / ".pushary" / "config.json"
|
|
585
|
+
try:
|
|
586
|
+
value = json.loads(path.read_text(encoding="utf-8")).get("apiKey")
|
|
587
|
+
return value if isinstance(value, str) and value else None
|
|
588
|
+
except Exception:
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _get_api_key():
|
|
593
|
+
key = _configured_key()
|
|
594
|
+
if not key:
|
|
595
|
+
raise ValueError(
|
|
596
|
+
f"{API_KEY_VARIABLE} is not set. Save it in Hermes with: hermes config set {API_KEY_VARIABLE} <your key>, "
|
|
597
|
+
"then restart Hermes. Get a key at https://pushary.com/sign-up?from=hermes"
|
|
598
|
+
)
|
|
599
|
+
return key
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _with_versions(payload):
|
|
603
|
+
for field, value in (
|
|
604
|
+
("adapterVersion", installation.LOADED_PLUGIN_VERSION),
|
|
605
|
+
("agentVersion", installation.LOADED_HERMES_VERSION),
|
|
606
|
+
):
|
|
607
|
+
if value:
|
|
608
|
+
payload[field] = value
|
|
609
|
+
return payload
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _agent_event_request(payload):
|
|
613
|
+
return urllib.request.Request(
|
|
614
|
+
f"{BASE_URL}/api/agent/event",
|
|
615
|
+
data=json.dumps(_with_identity(payload)).encode("utf-8"),
|
|
616
|
+
headers={
|
|
617
|
+
"Content-Type": "application/json",
|
|
618
|
+
"Authorization": f"Bearer {_get_api_key()}",
|
|
619
|
+
},
|
|
620
|
+
method="POST",
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def report_plugin_active(session_id):
|
|
625
|
+
try:
|
|
626
|
+
request = _agent_event_request(_with_versions({
|
|
627
|
+
"event": PLUGIN_ACTIVE_EVENT,
|
|
628
|
+
"agentType": "hermes",
|
|
629
|
+
"agentName": identity.agent_name(),
|
|
630
|
+
"sessionId": session_id,
|
|
631
|
+
}))
|
|
632
|
+
with urllib.request.urlopen(request, timeout=PLUGIN_ACTIVE_TIMEOUT_SECONDS) as response:
|
|
633
|
+
return 200 <= response.status < 300
|
|
634
|
+
except Exception:
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def agent_event(event, session_id, can_drain=False):
|
|
639
|
+
"""Report a Hermes session and pick up a queued notch instruction."""
|
|
640
|
+
try:
|
|
641
|
+
pending = _read_pending(session_id) if can_drain else None
|
|
642
|
+
req = _agent_event_request({
|
|
643
|
+
"event": event,
|
|
644
|
+
"agentType": "hermes",
|
|
645
|
+
"agentName": identity.agent_name(),
|
|
646
|
+
"sessionId": session_id,
|
|
647
|
+
"canDrainCommand": can_drain and pending is None,
|
|
648
|
+
"ackMode": can_drain and pending is None,
|
|
649
|
+
"skipCommandDrain": pending is not None,
|
|
650
|
+
})
|
|
651
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
652
|
+
raw = resp.read().decode("utf-8")
|
|
653
|
+
result = json.loads(raw) if raw.strip() else {}
|
|
654
|
+
if not isinstance(result, dict):
|
|
655
|
+
return result
|
|
656
|
+
if pending:
|
|
657
|
+
result.pop("pendingCommand", None)
|
|
658
|
+
result.pop("pendingCommandId", None)
|
|
659
|
+
if not _ack_command(pending["id"], session_id):
|
|
660
|
+
return result
|
|
661
|
+
if pending.get("delivered") is True:
|
|
662
|
+
_remove_pending(session_id, pending["id"])
|
|
663
|
+
return result
|
|
664
|
+
delivered = {**pending, "delivered": True}
|
|
665
|
+
if not _write_pending(session_id, delivered):
|
|
666
|
+
return result
|
|
667
|
+
result["pendingCommand"] = pending["text"]
|
|
668
|
+
result["pendingCommandId"] = pending["id"]
|
|
669
|
+
return result
|
|
670
|
+
command_id = result.get("pendingCommandId")
|
|
671
|
+
command = result.get("pendingCommand")
|
|
672
|
+
if isinstance(command_id, str) and isinstance(command, str):
|
|
673
|
+
received = {
|
|
674
|
+
"id": command_id,
|
|
675
|
+
"text": command,
|
|
676
|
+
"expiresAtMs": result.get("pendingCommandExpiresAtMs", int(time.time() * 1000) + 3_600_000),
|
|
677
|
+
"delivered": False,
|
|
678
|
+
}
|
|
679
|
+
saved = _write_pending(session_id, received, exclusive=True)
|
|
680
|
+
if not saved or not _ack_command(command_id, session_id):
|
|
681
|
+
result.pop("pendingCommand", None)
|
|
682
|
+
result.pop("pendingCommandId", None)
|
|
683
|
+
return result
|
|
684
|
+
if not _write_pending(session_id, {**received, "delivered": True}):
|
|
685
|
+
result.pop("pendingCommand", None)
|
|
686
|
+
result.pop("pendingCommandId", None)
|
|
687
|
+
return result
|
|
688
|
+
except urllib.error.HTTPError as exc:
|
|
689
|
+
return {"error": f"HTTP {exc.code}: {exc.reason}"}
|
|
690
|
+
except Exception as exc:
|
|
691
|
+
return {"error": str(exc)}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _mcp_call(tool_name, params):
|
|
695
|
+
api_key = _get_api_key()
|
|
696
|
+
url = f"{BASE_URL}/api/mcp/mcp"
|
|
697
|
+
payload = json.dumps({
|
|
698
|
+
"jsonrpc": "2.0",
|
|
699
|
+
"id": 1,
|
|
700
|
+
"method": "tools/call",
|
|
701
|
+
"params": {"name": tool_name, "arguments": params},
|
|
702
|
+
}).encode("utf-8")
|
|
703
|
+
|
|
704
|
+
req = urllib.request.Request(
|
|
705
|
+
url,
|
|
706
|
+
data=payload,
|
|
707
|
+
headers={
|
|
708
|
+
"Content-Type": "application/json",
|
|
709
|
+
"Accept": "application/json, text/event-stream",
|
|
710
|
+
"Authorization": f"Bearer {api_key}",
|
|
711
|
+
},
|
|
712
|
+
method="POST",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
blocking = tool_name in ("wait_for_answer", "propose_scope") or (tool_name == "ask_user" and params.get("wait", True))
|
|
716
|
+
timeout = min(max(params.get("timeoutMs", 30000), 1000), 55000) / 1000 + 2 if blocking else 4 if tool_name == "cancel_question" else 10
|
|
717
|
+
try:
|
|
718
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
719
|
+
raw = resp.read().decode("utf-8")
|
|
720
|
+
content_type = resp.headers.get_content_type()
|
|
721
|
+
except urllib.error.HTTPError as e:
|
|
722
|
+
detail = ""
|
|
723
|
+
try:
|
|
724
|
+
detail = e.read().decode("utf-8").strip()[:200]
|
|
725
|
+
except Exception:
|
|
726
|
+
pass
|
|
727
|
+
return {"error": f"HTTP {e.code}: {e.reason}" + (f" \u2014 {detail}" if detail else "")}
|
|
728
|
+
except Exception as e:
|
|
729
|
+
return {"error": str(e)}
|
|
730
|
+
|
|
731
|
+
if "text/event-stream" in (content_type or ""):
|
|
732
|
+
body = _parse_sse(raw)
|
|
733
|
+
else:
|
|
734
|
+
try:
|
|
735
|
+
body = json.loads(raw) if raw.strip() else None
|
|
736
|
+
except json.JSONDecodeError:
|
|
737
|
+
body = None
|
|
738
|
+
|
|
739
|
+
if not body:
|
|
740
|
+
return {"error": "Empty response from Pushary"}
|
|
741
|
+
|
|
742
|
+
if "error" in body:
|
|
743
|
+
err = body["error"]
|
|
744
|
+
return {"error": err.get("message", str(err)) if isinstance(err, dict) else str(err)}
|
|
745
|
+
|
|
746
|
+
text = body.get("result", {}).get("content", [{}])[0].get("text", "{}")
|
|
747
|
+
try:
|
|
748
|
+
return json.loads(text)
|
|
749
|
+
except json.JSONDecodeError:
|
|
750
|
+
return {"raw": text}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
IDENTITY_PARAM_NAMES = {
|
|
754
|
+
"session_id": "sessionId",
|
|
755
|
+
"machine_id": "machineId",
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
DECISION_PARAM_NAMES = {
|
|
759
|
+
**IDENTITY_PARAM_NAMES,
|
|
760
|
+
"tool_name": "toolName",
|
|
761
|
+
"tool_target": "toolTarget",
|
|
762
|
+
"repo_key": "repoKey",
|
|
763
|
+
"intent": "intent",
|
|
764
|
+
"action": "action",
|
|
765
|
+
"blocker": "blocker",
|
|
766
|
+
"action_body": "actionBody",
|
|
767
|
+
"scope_path": "scopePath",
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _with_decision_fields(params, values, accepted=DECISION_PARAM_NAMES):
|
|
772
|
+
unknown = set(values) - set(accepted)
|
|
773
|
+
if unknown:
|
|
774
|
+
raise TypeError("unexpected parameters: " + ", ".join(sorted(unknown)))
|
|
775
|
+
for key, camel in accepted.items():
|
|
776
|
+
value = values.get(key)
|
|
777
|
+
if value:
|
|
778
|
+
params[camel] = value
|
|
779
|
+
return _with_identity(params)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _with_identity(params):
|
|
783
|
+
for camel, value in (
|
|
784
|
+
("sessionId", identity.session_id()),
|
|
785
|
+
("machineId", identity.machine_id()),
|
|
786
|
+
):
|
|
787
|
+
if value and not params.get(camel):
|
|
788
|
+
params[camel] = value
|
|
789
|
+
return params
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def send_notification(title, body, agent_name=None, context=None, **identity_fields):
|
|
793
|
+
params = {"title": title, "body": body}
|
|
794
|
+
if agent_name:
|
|
795
|
+
params["agentName"] = agent_name
|
|
796
|
+
if context:
|
|
797
|
+
params["context"] = context
|
|
798
|
+
return _mcp_call(
|
|
799
|
+
"send_notification",
|
|
800
|
+
_with_decision_fields(params, identity_fields, IDENTITY_PARAM_NAMES),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def schedule_reminder(body=None, title=None, in_minutes=None, at=None,
|
|
805
|
+
cancel_reminder_id=None, agent_name=None, env=None, request_id=None, reminder_id=None, **identity_fields):
|
|
806
|
+
if body is not None and request_id is None:
|
|
807
|
+
request_id = str(uuid4())
|
|
808
|
+
values = {"body": body, "title": title, "inMinutes": in_minutes, "at": at,
|
|
809
|
+
"cancelReminderId": cancel_reminder_id, "agentName": agent_name, "env": env,
|
|
810
|
+
"requestId": request_id, "reminderId": reminder_id}
|
|
811
|
+
params = {key: value for key, value in values.items() if value is not None}
|
|
812
|
+
return _mcp_call("schedule_reminder", _with_decision_fields(params, identity_fields, IDENTITY_PARAM_NAMES))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def ask_user(question, question_type="confirm", options=None, placeholder=None,
|
|
816
|
+
context=None, agent_name=None, wait=True, timeout_ms=None, **decision):
|
|
817
|
+
params = {"question": question, "type": question_type, "wait": wait}
|
|
818
|
+
if options:
|
|
819
|
+
params["options"] = options
|
|
820
|
+
if placeholder:
|
|
821
|
+
params["placeholder"] = placeholder
|
|
822
|
+
if context:
|
|
823
|
+
params["context"] = context
|
|
824
|
+
if agent_name:
|
|
825
|
+
params["agentName"] = agent_name
|
|
826
|
+
if timeout_ms is not None:
|
|
827
|
+
params["timeoutMs"] = min(max(int(timeout_ms), 1000), 55000)
|
|
828
|
+
return _mcp_call("ask_user", _with_decision_fields(params, decision))
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def propose_scope(done_when, session_id, allowed_paths=None, off_limits_paths=None,
|
|
832
|
+
agent_name=None, timeout_ms=None, promises=None):
|
|
833
|
+
# \`promises\` is appended LAST so the positional signature every released
|
|
834
|
+
# plugin calls keeps its meaning.
|
|
835
|
+
params = {"doneWhen": done_when, "sessionId": session_id}
|
|
836
|
+
if allowed_paths:
|
|
837
|
+
params["allowedPaths"] = allowed_paths
|
|
838
|
+
if off_limits_paths:
|
|
839
|
+
params["offLimitsPaths"] = off_limits_paths
|
|
840
|
+
if promises:
|
|
841
|
+
params["promises"] = promises
|
|
842
|
+
if agent_name:
|
|
843
|
+
params["agentName"] = agent_name
|
|
844
|
+
if timeout_ms is not None:
|
|
845
|
+
params["timeoutMs"] = min(max(int(timeout_ms), 1000), 55000)
|
|
846
|
+
return _mcp_call("propose_scope", _with_identity(params))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def wait_for_answer(correlation_id, timeout_ms=30000):
|
|
850
|
+
return _mcp_call("wait_for_answer", {
|
|
851
|
+
"correlationId": correlation_id,
|
|
852
|
+
"timeoutMs": min(max(int(timeout_ms), 1000), 55000),
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def cancel_question(correlation_id):
|
|
857
|
+
return _mcp_call("cancel_question", {"correlationId": correlation_id})
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# --- Partner tools: reach your OWN product's end-users on their phones -----------
|
|
861
|
+
#
|
|
862
|
+
# The four tools above speak MCP (/api/mcp/mcp), which targets the phones connected
|
|
863
|
+
# to the owner's workspace. Enrolling an end-user and the durable end-user decision
|
|
864
|
+
# ledger are REST-only, so the partner tools bypass MCP and call the Server API
|
|
865
|
+
# (/api/v1/server/*) directly with the same Bearer key. Both require the Partner plan.
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _rest_call(method, path, body=None, params=None):
|
|
869
|
+
"""Call the REST Server API directly and return the parsed JSON dict.
|
|
870
|
+
|
|
871
|
+
On a transport or HTTP error returns {"error": <message>}; a 403 carries the
|
|
872
|
+
"requires the Partner plan" message so the model can surface it rather than
|
|
873
|
+
retrying blindly.
|
|
874
|
+
"""
|
|
875
|
+
api_key = _get_api_key()
|
|
876
|
+
url = f"{SERVER_BASE}{path}"
|
|
877
|
+
if params:
|
|
878
|
+
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
|
879
|
+
if query:
|
|
880
|
+
url = f"{url}?{query}"
|
|
881
|
+
|
|
882
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
883
|
+
req = urllib.request.Request(
|
|
884
|
+
url,
|
|
885
|
+
data=data,
|
|
886
|
+
headers={
|
|
887
|
+
"Content-Type": "application/json",
|
|
888
|
+
"Authorization": f"Bearer {api_key}",
|
|
889
|
+
},
|
|
890
|
+
method=method,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
try:
|
|
894
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
895
|
+
raw = resp.read().decode("utf-8")
|
|
896
|
+
except urllib.error.HTTPError as e:
|
|
897
|
+
detail = ""
|
|
898
|
+
try:
|
|
899
|
+
parsed = json.loads(e.read().decode("utf-8"))
|
|
900
|
+
detail = parsed.get("error") or parsed.get("message") or ""
|
|
901
|
+
except Exception:
|
|
902
|
+
detail = ""
|
|
903
|
+
return {"error": detail or f"HTTP {e.code}: {e.reason}"}
|
|
904
|
+
except Exception as e:
|
|
905
|
+
return {"error": str(e)}
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
return json.loads(raw) if raw.strip() else {}
|
|
909
|
+
except json.JSONDecodeError:
|
|
910
|
+
return {"error": "Invalid response from Pushary"}
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def enroll_end_user(external_id):
|
|
914
|
+
"""Mint a keyless one-tap connect link for one of your end-users."""
|
|
915
|
+
return _rest_call("POST", "/enroll", body={"externalId": external_id})
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def create_end_user_decision(question, external_id, question_type="confirm",
|
|
919
|
+
options=None, agent_name=None, context=None,
|
|
920
|
+
idempotency_key=None):
|
|
921
|
+
"""Open a durable decision addressed to a specific end-user (does not wait)."""
|
|
922
|
+
body = {
|
|
923
|
+
"question": question,
|
|
924
|
+
"type": question_type,
|
|
925
|
+
"externalId": external_id,
|
|
926
|
+
"wait": False,
|
|
927
|
+
}
|
|
928
|
+
if options:
|
|
929
|
+
body["options"] = options
|
|
930
|
+
if agent_name:
|
|
931
|
+
body["agentName"] = agent_name
|
|
932
|
+
if context:
|
|
933
|
+
body["context"] = context
|
|
934
|
+
if idempotency_key:
|
|
935
|
+
body["idempotencyKey"] = idempotency_key
|
|
936
|
+
return _rest_call("POST", "/decisions", body=body)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def get_end_user_decision(decision_id, wait_seconds=45):
|
|
940
|
+
"""Durably long-poll a decision (server caps wait at 55s)."""
|
|
941
|
+
seg = urllib.parse.quote(str(decision_id), safe="")
|
|
942
|
+
return _rest_call("GET", f"/decisions/{seg}", params={"wait": wait_seconds})
|
|
943
|
+
` },
|
|
944
|
+
{ path: "approval.py", contents: 'import time\n\nfrom . import api, identity\n\nHERMES_SHELL_TOOL = "terminal"\nDECISION_LINE_MAX = 500\nACTION_BODY_MAX = 4000\n\nPOLL_CEILING_SECONDS = 50.0\n\nCHOICE_LABELS = (\n ("once", "Allow once"),\n ("session", "Allow for this session"),\n ("always", "Always allow"),\n ("deny", "Deny"),\n)\n\n\nclass TransportUnavailable(Exception):\n pass\n\n\nQUESTION_COMMAND_MAX = 160\n\nPLUGIN_RULE_MARKER = "(plugin approval rule)"\n\n\ndef _offered(allowed):\n return [pair for pair in CHOICE_LABELS if pair[0] in set(allowed or ())]\n\n\ndef _command_head(command):\n return " ".join(str(command or "").split()[:2])[:80]\n\n\ndef _command_summary(command):\n collapsed = " ".join(str(command or "").split())\n if len(collapsed) <= QUESTION_COMMAND_MAX:\n return collapsed\n return collapsed[:QUESTION_COMMAND_MAX - 1] + "\u2026"\n\n\ndef _choice_from_answer(value, offered, uses_select):\n answer = str(value or "").strip()\n if uses_select:\n for choice, label in offered:\n if answer.lower() == label.lower():\n return choice\n return "deny"\n return "once" if api.is_affirmative(answer) else "deny"\n\n\ndef _subject(request):\n """What to put in the question.\n\n A dangerous-command approval carries the real command. A plugin rule\n escalated from pre_tool_call carries a synthetic display label instead, and\n the plugin\'s own message is the part worth reading, so use that.\n """\n command = str(request.command or "")\n if PLUGIN_RULE_MARKER in command:\n lines = str(request.description or "").strip().splitlines()\n if lines and lines[0].strip():\n return _command_summary(lines[0])\n return _command_summary(command)\n\n\ndef _open_question(request, offered, uses_select):\n command = str(request.command or "")\n return api.ask_user(\n question=f"Allow Hermes to run `{_subject(request)}`?",\n question_type="select" if uses_select else "confirm",\n options=[label for _, label in offered] if uses_select else None,\n context=str(request.description or "")[:500] or None,\n agent_name=identity.agent_name(),\n session_id=identity.session_id(),\n machine_id=identity.machine_id(),\n tool_name=HERMES_SHELL_TOOL,\n tool_target=_command_head(command),\n action=command[:DECISION_LINE_MAX] or None,\n action_body=command[:ACTION_BODY_MAX] or None,\n blocker=(\n f"Hermes flagged this command as {request.pattern_key}"[:DECISION_LINE_MAX]\n if request.pattern_key\n else None\n ),\n wait=False,\n )\n\n\ndef _stopped(result):\n return result.get("handoffAction") == "stop" or result.get("status") in ("cancelled", "unavailable", "stopped")\n\n\ndef _withdraw(correlation_id):\n try:\n cancelled = api.cancel_question(correlation_id)\n if _stopped(cancelled) or cancelled.get("error"):\n return {"handoffAction": "stop"}\n if cancelled.get("cancelled") is True:\n return {}\n answer = api.wait_for_answer(correlation_id, 1000)\n if answer.get("answered") or answer.get("status") in ("expired", "missing"):\n return answer\n except Exception:\n pass\n return {"handoffAction": "stop"}\n\n\ndef present(request):\n offered = _offered(request.allowed_choices)\n if not offered:\n return request.respond("deny")\n uses_select = [choice for choice, _ in offered] != ["once", "deny"]\n # Reserve time for withdrawal before the host discards a late decision.\n started_at = time.monotonic()\n deadline = started_at + max(float(request.timeout_seconds or 0) - 10, 0.0)\n created = _open_question(request, offered, uses_select)\n if created.get("answered"):\n return request.respond(_choice_from_answer(created.get("value"), offered, uses_select))\n if _stopped(created):\n return request.respond("deny")\n correlation_id = created.get("correlationId")\n if not correlation_id:\n raise TransportUnavailable("Pushary did not create an approval")\n\n policy_deadline = None\n window_ms = created.get("policyTimeoutMs")\n if created.get("deliveryMode") == "push_first" and isinstance(window_ms, (int, float)) and window_ms >= 0:\n policy_deadline = started_at + window_ms / 1000\n deadline = min(deadline, policy_deadline)\n handoff = created.get("noDevices") or created.get("suppressed") or created.get("status") in ("terminal", "notified")\n answer = created\n try:\n while not handoff and time.monotonic() < deadline:\n remaining = deadline - time.monotonic()\n answer = api.wait_for_answer(correlation_id, int(min(POLL_CEILING_SECONDS, max(1.0, remaining)) * 1000))\n if answer.get("answered"):\n return request.respond(_choice_from_answer(answer.get("value"), offered, uses_select))\n if _stopped(answer) or answer.get("error") or answer.get("status") in ("expired", "missing"):\n break\n # Some hosts/proxies return pending immediately instead of long-polling.\n time.sleep(min(0.1, max(0, deadline - time.monotonic())))\n except Exception:\n answer = {"handoffAction": "stop"}\n\n late = _withdraw(correlation_id)\n if _stopped(answer) or _stopped(late):\n return request.respond("deny")\n if late.get("answered"):\n return request.respond(_choice_from_answer(late.get("value"), offered, uses_select))\n if handoff or (policy_deadline is not None and time.monotonic() >= policy_deadline):\n # Only a fenced question may reach Hermes\'s configured builtin fallback.\n raise TransportUnavailable("Approval handed back to Hermes")\n return request.respond("deny")\n' },
|
|
945
|
+
{ path: "host.py", contents: 'import os\n\nTRANSPORT_NAME = "pushary"\n\nHOOK_TIMEOUT_DEFAULT_SECONDS = 30.0\nHOOK_TIMEOUT_MAX_SECONDS = 600.0\n\nGATE_HEADROOM_SECONDS = 5.0\nGATE_MIN_SECONDS = 5.0\nGATE_MAX_SECONDS = 55.0\n\n# The last Hermes that runs a pre_tool_call callback to completion. Anything\n# after this abandons one that outlives plugins.hook_callback_timeout.\nUNBOUNDED_THROUGH_VERSION = (0, 20, 5)\n\n\ndef _config():\n try:\n from hermes_cli.config import load_config_readonly\n\n loaded = load_config_readonly()\n except Exception:\n return {}\n return loaded if isinstance(loaded, dict) else {}\n\n\ndef _section(node, *path):\n for key in path:\n node = node.get(key) if isinstance(node, dict) else None\n if node is None:\n return {}\n return node if isinstance(node, dict) else {}\n\n\ndef profile_secret(name):\n try:\n from agent.secret_scope import get_secret\n except Exception:\n return os.environ.get(name) or None\n try:\n return get_secret(name) or None\n except Exception:\n return None\n\n\ndef hermes_version_label():\n try:\n from importlib.metadata import version\n\n return version("hermes-cli")\n except Exception:\n try:\n import hermes_cli\n\n label = getattr(hermes_cli, "__version__", None)\n except Exception:\n return None\n return str(label) if label else None\n\n\ndef _hermes_version():\n """This Hermes as a comparable tuple, or None when it will not say."""\n raw = hermes_version_label()\n if not raw:\n return None\n parts = []\n for piece in str(raw).split(".")[:3]:\n digits = ""\n for char in piece:\n if not char.isdigit():\n break\n digits += char\n if not digits:\n return None\n parts.append(int(digits))\n while len(parts) < 3:\n parts.append(0)\n return tuple(parts)\n\n\ndef bounds_pre_tool_call():\n """Whether this Hermes abandons a slow pre_tool_call callback.\n\n The timeout was added after 0.20.5, which runs plugin hooks to completion.\n Clamping the wait on a host that would never have cut it off just shortens\n the window the user has to answer, for nothing.\n\n Unreadable means assume bounded. A wait that is shorter than it had to be\n costs the user seconds; a wait longer than the host allows costs them the\n answer they already gave.\n\n That rule is why the private symbol is not asked on its own. `hasattr` on\n `_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS` cannot tell "this host runs hooks to\n completion" apart from "the private name moved", and those two answers want\n opposite behaviour: the first earns the full window, the second spends 55\n seconds against a budget of 30 and loses an answer the user already gave.\n A rename is the likelier of the two, because a private name is the one thing\n a host is free to change. So the symbol is taken as proof of bounded, and\n only a version this side of the timeout landing is taken as proof of the\n other. Anything unreadable falls to bounded.\n """\n try:\n from hermes_cli import plugins\n except Exception:\n return True\n if hasattr(plugins, "_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS"):\n return True\n version = _hermes_version()\n if version is None:\n return True\n return version > UNBOUNDED_THROUGH_VERSION\n\n\ndef hook_callback_timeout_seconds():\n raw = _section(_config(), "plugins").get("hook_callback_timeout")\n if raw is None:\n return HOOK_TIMEOUT_DEFAULT_SECONDS\n try:\n timeout = float(raw)\n except (TypeError, ValueError):\n return HOOK_TIMEOUT_DEFAULT_SECONDS\n if timeout < 0:\n return HOOK_TIMEOUT_DEFAULT_SECONDS\n return min(timeout, HOOK_TIMEOUT_MAX_SECONDS)\n\n\ndef gate_budget_seconds(requested_seconds):\n if not bounds_pre_tool_call():\n return max(1.0, min(float(requested_seconds), GATE_MAX_SECONDS))\n timeout = hook_callback_timeout_seconds()\n ceiling = (\n GATE_MAX_SECONDS\n if timeout <= 0\n else max(GATE_MIN_SECONDS, timeout - GATE_HEADROOM_SECONDS)\n )\n return max(1.0, min(float(requested_seconds), ceiling, GATE_MAX_SECONDS))\n\n\ndef pushary_is_selected_transport():\n selected = _section(_config(), "security", "approval").get("transport")\n return str(selected or "").strip().lower() == TRANSPORT_NAME\n' },
|
|
946
|
+
{ path: "identity.py", contents: 'import hashlib\nimport os\nimport re\nimport socket\nfrom pathlib import Path\n\nDEFAULT_AGENT_NAME = "Hermes"\nMAX_ID_LENGTH = 128\nSHARED_MACHINE_ID_FILE = Path.home() / ".pushary" / "machine-id"\nSHARED_MACHINE_ID_PATTERN = re.compile(r"^[A-Za-z0-9-]{1,128}$")\n\n_session_id = None\n\n\ndef agent_name():\n return os.environ.get("PUSHARY_AGENT_NAME", DEFAULT_AGENT_NAME)\n\n\ndef labelled_agent_name(requested):\n project = str(requested or "").partition(" - ")[2].strip()\n return f"{agent_name()} - {project}" if project else agent_name()\n\n\ndef shared_machine_id(path=None):\n try:\n value = (path or SHARED_MACHINE_ID_FILE).read_text(encoding="utf-8").strip()\n except OSError:\n return None\n return value if SHARED_MACHINE_ID_PATTERN.match(value) else None\n\n\ndef hostname_machine_id():\n try:\n host = socket.gethostname()\n except Exception:\n return None\n if not host:\n return None\n return hashlib.sha256(host.encode("utf-8")).hexdigest()[:8]\n\n\ndef machine_id():\n return shared_machine_id() or hostname_machine_id()\n\n\ndef remember_session(value):\n global _session_id\n if value:\n _session_id = str(value)[:MAX_ID_LENGTH]\n\n\ndef forget_session():\n global _session_id\n _session_id = None\n\n\ndef session_id():\n return _session_id\n' },
|
|
947
|
+
{ path: "installation.py", contents: `import json
|
|
948
|
+
import logging
|
|
949
|
+
import os
|
|
950
|
+
import time
|
|
951
|
+
from datetime import datetime, timezone
|
|
952
|
+
from pathlib import Path
|
|
953
|
+
|
|
954
|
+
from . import host
|
|
955
|
+
|
|
956
|
+
logger = logging.getLogger(__name__)
|
|
957
|
+
|
|
958
|
+
PLUGIN_DIRECTORY = Path(__file__).parent
|
|
959
|
+
MANIFEST_FILE = "plugin.yaml"
|
|
960
|
+
INSTALL_MARKER_FILE = ".pushary-install.json"
|
|
961
|
+
INSTALL_MARKER_OWNER = "pushary"
|
|
962
|
+
HERMES_PLUGINS_FOLDER = "plugins"
|
|
963
|
+
PLUGIN_DATA_NAMESPACE = "pushary"
|
|
964
|
+
LOAD_RECORDS_DIRECTORY = "loaded"
|
|
965
|
+
LOAD_RECORD_RETENTION_SECONDS = 7 * 24 * 60 * 60
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def plugin_version(directory=None):
|
|
969
|
+
try:
|
|
970
|
+
manifest = ((directory or PLUGIN_DIRECTORY) / MANIFEST_FILE).read_text(encoding="utf-8")
|
|
971
|
+
except OSError:
|
|
972
|
+
return None
|
|
973
|
+
for line in manifest.splitlines():
|
|
974
|
+
key, separator, value = line.partition(":")
|
|
975
|
+
if separator and key.strip() == "version":
|
|
976
|
+
return value.strip().strip("'\\"") or None
|
|
977
|
+
return None
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
LOADED_PLUGIN_VERSION = plugin_version()
|
|
981
|
+
LOADED_HERMES_VERSION = host.hermes_version_label()
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def install_marker(directory=None):
|
|
985
|
+
try:
|
|
986
|
+
marker = json.loads(((directory or PLUGIN_DIRECTORY) / INSTALL_MARKER_FILE).read_text(encoding="utf-8"))
|
|
987
|
+
except (OSError, ValueError):
|
|
988
|
+
return None
|
|
989
|
+
if not isinstance(marker, dict) or marker.get("owner") != INSTALL_MARKER_OWNER:
|
|
990
|
+
return None
|
|
991
|
+
return marker
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def follows_catalog_rules(directory=None):
|
|
995
|
+
folder = directory or PLUGIN_DIRECTORY
|
|
996
|
+
return folder.parent.name == HERMES_PLUGINS_FOLDER and install_marker(folder) is None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def routes_questions(directory=None):
|
|
1000
|
+
marker = install_marker(directory)
|
|
1001
|
+
return bool(marker and marker.get("routeQuestions") is True)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def hermes_home():
|
|
1005
|
+
try:
|
|
1006
|
+
from hermes_constants import get_hermes_home
|
|
1007
|
+
|
|
1008
|
+
return Path(get_hermes_home())
|
|
1009
|
+
except Exception:
|
|
1010
|
+
configured = os.environ.get("HERMES_HOME")
|
|
1011
|
+
return Path(configured).expanduser() if configured else Path.home() / ".hermes"
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def load_records_directory(home=None):
|
|
1015
|
+
return (home or hermes_home()) / "plugin-data" / PLUGIN_DATA_NAMESPACE / LOAD_RECORDS_DIRECTORY
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def load_record(approval_transport_registered, pid, loaded_at):
|
|
1019
|
+
return {
|
|
1020
|
+
"pluginVersion": LOADED_PLUGIN_VERSION,
|
|
1021
|
+
"hermesVersion": LOADED_HERMES_VERSION,
|
|
1022
|
+
"pid": pid,
|
|
1023
|
+
"approvalTransport": bool(approval_transport_registered),
|
|
1024
|
+
"loadedAt": loaded_at,
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _process_is_running(pid):
|
|
1029
|
+
if os.name == "nt":
|
|
1030
|
+
return True
|
|
1031
|
+
try:
|
|
1032
|
+
os.kill(pid, 0)
|
|
1033
|
+
except ProcessLookupError:
|
|
1034
|
+
return False
|
|
1035
|
+
except OSError:
|
|
1036
|
+
return True
|
|
1037
|
+
return True
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _record_belongs_to_running_process(record):
|
|
1041
|
+
return record.stem.isdigit() and _process_is_running(int(record.stem))
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _prune_expired_load_records(directory, keep, now):
|
|
1045
|
+
for record in directory.glob("*.json"):
|
|
1046
|
+
if record.name == keep:
|
|
1047
|
+
continue
|
|
1048
|
+
try:
|
|
1049
|
+
expired = now - record.stat().st_mtime > LOAD_RECORD_RETENTION_SECONDS
|
|
1050
|
+
if expired and not _record_belongs_to_running_process(record):
|
|
1051
|
+
record.unlink()
|
|
1052
|
+
except OSError:
|
|
1053
|
+
continue
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def record_plugin_load(approval_transport_registered, home=None, pid=None, now=None):
|
|
1057
|
+
process_id = os.getpid() if pid is None else pid
|
|
1058
|
+
moment = time.time() if now is None else now
|
|
1059
|
+
directory = load_records_directory(home)
|
|
1060
|
+
record_name = f"{process_id}.json"
|
|
1061
|
+
try:
|
|
1062
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1063
|
+
loaded_at = datetime.fromtimestamp(moment, timezone.utc).isoformat()
|
|
1064
|
+
staged = directory / f".{record_name}.tmp"
|
|
1065
|
+
staged.write_text(
|
|
1066
|
+
json.dumps(load_record(approval_transport_registered, process_id, loaded_at)),
|
|
1067
|
+
encoding="utf-8",
|
|
1068
|
+
)
|
|
1069
|
+
os.replace(staged, directory / record_name)
|
|
1070
|
+
_prune_expired_load_records(directory, record_name, moment)
|
|
1071
|
+
return directory / record_name
|
|
1072
|
+
except OSError as exc:
|
|
1073
|
+
logger.debug("[pushary] could not record the plugin load: %s", exc)
|
|
1074
|
+
return None
|
|
1075
|
+
` },
|
|
1076
|
+
{ path: "plugin.yaml", contents: "name: pushary\nversion: 0.8.0\ndescription: Push notifications and human-in-the-loop for Hermes Agent via Pushary\nauthor: RalphNex OU\nrequires_env:\n - name: PUSHARY_API_KEY\n description: Your Pushary API key\n url: https://pushary.com/sign-up?from=hermes\n secret: true\nprovides_tools:\n - pushary_remind\n - pushary_notify\n - pushary_ask\n - pushary_wait\n - pushary_cancel\n - pushary_propose_scope\n - pushary_enroll\n - pushary_ask_end_user\nprovides_hooks:\n - pre_llm_call\n - pre_tool_call\n - post_tool_call\n - on_session_start\n - on_session_end\n - on_session_finalize\nprovides_approval_transports:\n - pushary\n" },
|
|
1077
|
+
{ path: "schemas.py", contents: `PUSHARY_REMIND = {
|
|
1078
|
+
"name": "pushary_remind",
|
|
1079
|
+
"description": "Schedule a one-time personal reminder on Pushary, even after Hermes exits. Use body plus in_minutes or at (ISO timestamp with explicit UTC offset, within 30 days). Never guess the user's timezone. Confirm the returned time. No arguments lists pending reminders; cancel_reminder_id cancels a scheduled one. Use Hermes cron for agent work; use this for reminding the operator.",
|
|
1080
|
+
"parameters": {
|
|
1081
|
+
"type": "object",
|
|
1082
|
+
"properties": {
|
|
1083
|
+
"request_id": {"type": "string", "description": "Unique scheduling operation ID. Reuse unchanged when retrying."},
|
|
1084
|
+
"reminder_id": {"type": "string", "description": "Look up your reminder outcome instead of scheduling or cancelling."},
|
|
1085
|
+
"body": {"type": "string", "maxLength": 180, "description": "At most 180 UTF-16 units after secret redaction; emoji may use two."},
|
|
1086
|
+
"title": {"type": "string", "maxLength": 100, "description": "At most 100 UTF-16 units after secret redaction; emoji may use two."},
|
|
1087
|
+
"in_minutes": {"type": "integer", "minimum": 1, "maximum": 43200},
|
|
1088
|
+
"at": {"type": "string", "description": "Future ISO 8601 time with Z or UTC offset."},
|
|
1089
|
+
"cancel_reminder_id": {"type": "string"},
|
|
1090
|
+
"agent_name": {"type": "string"},
|
|
1091
|
+
"session_id": {"type": "string"},
|
|
1092
|
+
"machine_id": {"type": "string"},
|
|
1093
|
+
"env": {"type": "string", "enum": ["test"]},
|
|
1094
|
+
},
|
|
1095
|
+
"additionalProperties": False,
|
|
1096
|
+
},
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
PUSHARY_NOTIFY = {
|
|
1100
|
+
"name": "pushary_notify",
|
|
1101
|
+
"description": (
|
|
1102
|
+
"Send a push notification to the user's phone or desktop. Use when a task "
|
|
1103
|
+
"completes, an error occurs, or a long-running process finishes. The user "
|
|
1104
|
+
"sees this on their lock screen even if they're away from the computer. "
|
|
1105
|
+
"Optionally include structured context with file changes, error details, "
|
|
1106
|
+
"and next steps."
|
|
1107
|
+
),
|
|
1108
|
+
"parameters": {
|
|
1109
|
+
"type": "object",
|
|
1110
|
+
"properties": {
|
|
1111
|
+
"title": {
|
|
1112
|
+
"type": "string",
|
|
1113
|
+
"description": "Notification title (max 100 chars, aim for under 60)",
|
|
1114
|
+
},
|
|
1115
|
+
"body": {
|
|
1116
|
+
"type": "string",
|
|
1117
|
+
"description": "Notification body (max 500 chars, aim for under 200)",
|
|
1118
|
+
},
|
|
1119
|
+
"agent_name": {
|
|
1120
|
+
"type": "string",
|
|
1121
|
+
"description": 'Identifies this Hermes instance (e.g., "Hermes - daily-briefing")',
|
|
1122
|
+
},
|
|
1123
|
+
"context_type": {
|
|
1124
|
+
"type": "string",
|
|
1125
|
+
"enum": ["task_complete", "error", "info"],
|
|
1126
|
+
"description": (
|
|
1127
|
+
"Always pass this. It marks the notification a task update, and the "
|
|
1128
|
+
"user's setting for where task updates land can only route one that "
|
|
1129
|
+
"says so. Also drives the rich detail page."
|
|
1130
|
+
),
|
|
1131
|
+
},
|
|
1132
|
+
"summary": {
|
|
1133
|
+
"type": "string",
|
|
1134
|
+
"description": "Short summary shown on the detail page",
|
|
1135
|
+
},
|
|
1136
|
+
"details": {
|
|
1137
|
+
"type": "array",
|
|
1138
|
+
"items": {"type": "string"},
|
|
1139
|
+
"description": "Bullet-point details",
|
|
1140
|
+
},
|
|
1141
|
+
"files_changed": {
|
|
1142
|
+
"type": "array",
|
|
1143
|
+
"items": {"type": "string"},
|
|
1144
|
+
"description": "List of files that were changed",
|
|
1145
|
+
},
|
|
1146
|
+
"error_message": {
|
|
1147
|
+
"type": "string",
|
|
1148
|
+
"description": "Error message if context_type is error",
|
|
1149
|
+
},
|
|
1150
|
+
"next_steps": {
|
|
1151
|
+
"type": "string",
|
|
1152
|
+
"description": "Suggested next steps for the user",
|
|
1153
|
+
},
|
|
1154
|
+
"session_id": {
|
|
1155
|
+
"type": "string",
|
|
1156
|
+
"description": "Per-session id, so parallel Hermes sessions are attributed separately. Filled in automatically when omitted.",
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
"required": ["title", "body"],
|
|
1160
|
+
},
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
PUSHARY_ASK = {
|
|
1164
|
+
"name": "pushary_ask",
|
|
1165
|
+
"description": (
|
|
1166
|
+
"Ask the user a question via push notification and wait for their answer. "
|
|
1167
|
+
"By default it performs the initial wait and at most one server-directed poll. "
|
|
1168
|
+
"Returns an answer or a structured unanswered result; follow handoffAction "
|
|
1169
|
+
"when present, otherwise nextAction. "
|
|
1170
|
+
"Supports three question types: confirm (yes/no), select (multiple choice "
|
|
1171
|
+
"with 2-6 options), and input (free text). Set wait=false only when you want "
|
|
1172
|
+
"an immediate correlationId and will continue pushary_wait while the question is pending."
|
|
1173
|
+
),
|
|
1174
|
+
"parameters": {
|
|
1175
|
+
"type": "object",
|
|
1176
|
+
"properties": {
|
|
1177
|
+
"question": {
|
|
1178
|
+
"type": "string",
|
|
1179
|
+
"description": "The question to ask (max 8000 chars)",
|
|
1180
|
+
},
|
|
1181
|
+
"type": {
|
|
1182
|
+
"type": "string",
|
|
1183
|
+
"enum": ["confirm", "select", "input"],
|
|
1184
|
+
"description": "Question type: confirm (yes/no), select (pick from options), input (free text)",
|
|
1185
|
+
"default": "confirm",
|
|
1186
|
+
},
|
|
1187
|
+
"options": {
|
|
1188
|
+
"type": "array",
|
|
1189
|
+
"items": {"type": "string"},
|
|
1190
|
+
"description": "Choices for select type (2-6 options)",
|
|
1191
|
+
},
|
|
1192
|
+
"placeholder": {
|
|
1193
|
+
"type": "string",
|
|
1194
|
+
"description": "Placeholder text for input type",
|
|
1195
|
+
},
|
|
1196
|
+
"context": {
|
|
1197
|
+
"type": "string",
|
|
1198
|
+
"description": "What you're working on, shown above the question",
|
|
1199
|
+
},
|
|
1200
|
+
"agent_name": {
|
|
1201
|
+
"type": "string",
|
|
1202
|
+
"description": 'Identifies this Hermes instance (e.g., "Hermes - server-maintenance")',
|
|
1203
|
+
},
|
|
1204
|
+
"wait": {
|
|
1205
|
+
"type": "boolean",
|
|
1206
|
+
"description": "Wait for the answer before returning (default true). Set false to get correlationId for manual polling.",
|
|
1207
|
+
"default": True,
|
|
1208
|
+
},
|
|
1209
|
+
"timeout_ms": {
|
|
1210
|
+
"type": "integer",
|
|
1211
|
+
"description": "How long to wait per poll attempt in ms (default 30000, max 55000)",
|
|
1212
|
+
"default": 30000,
|
|
1213
|
+
},
|
|
1214
|
+
"tool_name": {
|
|
1215
|
+
"type": "string",
|
|
1216
|
+
"description": "The tool this approval is for (e.g. \\"terminal\\"), so the user can choose to always-allow it.",
|
|
1217
|
+
},
|
|
1218
|
+
"tool_target": {
|
|
1219
|
+
"type": "string",
|
|
1220
|
+
"description": "Compact target of the tool call, e.g. the command head \\"git push\\" or a file extension. Used to mine policy suggestions.",
|
|
1221
|
+
},
|
|
1222
|
+
"repo_key": {
|
|
1223
|
+
"type": "string",
|
|
1224
|
+
"description": "Stable repository identity for the working directory, e.g. \\"github.com/acme/api\\", so a routing rule scoped to one repository does not govern another.",
|
|
1225
|
+
},
|
|
1226
|
+
"intent": {
|
|
1227
|
+
"type": "string",
|
|
1228
|
+
"description": "The user's stated task, one line. Shown as the Intent line so they can see why you stopped.",
|
|
1229
|
+
},
|
|
1230
|
+
"action": {
|
|
1231
|
+
"type": "string",
|
|
1232
|
+
"description": "The concrete operation about to happen, one line. Shown as the Action line.",
|
|
1233
|
+
},
|
|
1234
|
+
"blocker": {
|
|
1235
|
+
"type": "string",
|
|
1236
|
+
"description": "The single gating reason you stopped, one line. Shown as the Blocker line.",
|
|
1237
|
+
},
|
|
1238
|
+
"action_body": {
|
|
1239
|
+
"type": "string",
|
|
1240
|
+
"description": "The diff or full command, rendered as a collapsible detail block. Never used as the push body.",
|
|
1241
|
+
},
|
|
1242
|
+
"session_id": {
|
|
1243
|
+
"type": "string",
|
|
1244
|
+
"description": "Per-session id, so parallel Hermes sessions are attributed separately. Filled in automatically when omitted.",
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
"required": ["question"],
|
|
1248
|
+
},
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
PUSHARY_WAIT = {
|
|
1252
|
+
"name": "pushary_wait",
|
|
1253
|
+
"description": (
|
|
1254
|
+
"Poll once for a question created by pushary_ask with wait=false. Follow "
|
|
1255
|
+
"handoffAction when present, otherwise nextAction. Do not loop."
|
|
1256
|
+
),
|
|
1257
|
+
"parameters": {
|
|
1258
|
+
"type": "object",
|
|
1259
|
+
"properties": {
|
|
1260
|
+
"correlation_id": {
|
|
1261
|
+
"type": "string",
|
|
1262
|
+
"description": "The correlationId returned by pushary_ask",
|
|
1263
|
+
},
|
|
1264
|
+
"timeout_ms": {
|
|
1265
|
+
"type": "integer",
|
|
1266
|
+
"description": "How long to wait in milliseconds (default 30000, max 55000)",
|
|
1267
|
+
"default": 30000,
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
"required": ["correlation_id"],
|
|
1271
|
+
},
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
PUSHARY_CANCEL = {
|
|
1275
|
+
"name": "pushary_cancel",
|
|
1276
|
+
"description": (
|
|
1277
|
+
"Cancel a pending question so it can no longer be answered. Use when "
|
|
1278
|
+
"the question is no longer relevant."
|
|
1279
|
+
),
|
|
1280
|
+
"parameters": {
|
|
1281
|
+
"type": "object",
|
|
1282
|
+
"properties": {
|
|
1283
|
+
"correlation_id": {
|
|
1284
|
+
"type": "string",
|
|
1285
|
+
"description": "The correlationId of the question to cancel",
|
|
1286
|
+
},
|
|
1287
|
+
},
|
|
1288
|
+
"required": ["correlation_id"],
|
|
1289
|
+
},
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
PUSHARY_PROPOSE_SCOPE = {
|
|
1293
|
+
"name": "pushary_propose_scope",
|
|
1294
|
+
"description": (
|
|
1295
|
+
"Propose what this run will touch and block until the user ratifies it on "
|
|
1296
|
+
"their phone. Do not add a redundant approval to already authorized work. "
|
|
1297
|
+
"The user approves the paths you intend to change, the areas you promise to "
|
|
1298
|
+
"leave alone, and your definition of done in one tap. After that, editing a "
|
|
1299
|
+
"file outside the agreed scope becomes a separate 'wants to widen scope' "
|
|
1300
|
+
"question instead of a silent approval, so the user is asked once about the "
|
|
1301
|
+
"boundary rather than repeatedly about each file. ONLY file paths are "
|
|
1302
|
+
"enforced, and only on tool calls that carry one; shell commands, reads, web "
|
|
1303
|
+
"requests and MCP tools carry no path, so the contract says nothing about "
|
|
1304
|
+
"them. A boundary that is not a path (recipients, channels, spend, systems) "
|
|
1305
|
+
"goes in promises, which is shown and recorded but never checked. Read the "
|
|
1306
|
+
"returned enforces: an empty list means nothing here is checked "
|
|
1307
|
+
"automatically, and you must say so rather than report that a scope is in "
|
|
1308
|
+
"force. Returns ratified:true only on an explicit yes; "
|
|
1309
|
+
"anything else means proceed as if no scope was agreed."
|
|
1310
|
+
),
|
|
1311
|
+
"parameters": {
|
|
1312
|
+
"type": "object",
|
|
1313
|
+
"properties": {
|
|
1314
|
+
"done_when": {
|
|
1315
|
+
"type": "string",
|
|
1316
|
+
"description": "What \\"finished\\" means for this run, one or two lines.",
|
|
1317
|
+
},
|
|
1318
|
+
"allowed_paths": {
|
|
1319
|
+
"type": "array",
|
|
1320
|
+
"items": {"type": "string"},
|
|
1321
|
+
"description": "Globs you intend to change, e.g. [\\"src/**\\", \\"docs/*.md\\"].",
|
|
1322
|
+
},
|
|
1323
|
+
"off_limits_paths": {
|
|
1324
|
+
"type": "array",
|
|
1325
|
+
"items": {"type": "string"},
|
|
1326
|
+
"description": "Globs you promise not to touch. These win wherever they overlap allowed_paths.",
|
|
1327
|
+
},
|
|
1328
|
+
"promises": {
|
|
1329
|
+
"type": "array",
|
|
1330
|
+
"items": {"type": "string"},
|
|
1331
|
+
"description": (
|
|
1332
|
+
"Boundaries that are not file paths: recipients, channels, spend limits, "
|
|
1333
|
+
"systems you will not open. Up to 10 of 200 characters. Shown to the user "
|
|
1334
|
+
"as \\"Promised, not checked\\" and recorded, but NEVER enforced, because the "
|
|
1335
|
+
"gate judges a file path and these have none. Do not put these in allowed_paths."
|
|
1336
|
+
),
|
|
1337
|
+
},
|
|
1338
|
+
"agent_name": {
|
|
1339
|
+
"type": "string",
|
|
1340
|
+
"description": 'Identifies this Hermes instance (e.g., "Hermes - migration").',
|
|
1341
|
+
},
|
|
1342
|
+
"session_id": {
|
|
1343
|
+
"type": "string",
|
|
1344
|
+
"description": "Per-session id. Filled in from the running Hermes session when omitted.",
|
|
1345
|
+
},
|
|
1346
|
+
"timeout_ms": {
|
|
1347
|
+
"type": "integer",
|
|
1348
|
+
"description": "How long to block waiting for ratification (max 55000).",
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
"required": ["done_when"],
|
|
1352
|
+
},
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
# --- Partner tools (require the Partner plan) -----------------------------------
|
|
1356
|
+
# Reach your OWN product's end-users, not the operator's own devices. Use these when
|
|
1357
|
+
# Hermes runs as a product that serves many people (gateway mode), so each person
|
|
1358
|
+
# approves their own action on their own phone.
|
|
1359
|
+
|
|
1360
|
+
PUSHARY_ENROLL = {
|
|
1361
|
+
"name": "pushary_enroll",
|
|
1362
|
+
"description": (
|
|
1363
|
+
"Connect one of your OWN end-users' phones so they can approve actions "
|
|
1364
|
+
"(Partner plan). Returns a single-use link to show that person; one tap "
|
|
1365
|
+
"connects them through the Pushary app or supported browser fallback, without an operator account. Call once per "
|
|
1366
|
+
"end-user and reuse the enrollment, not the link, which expires. Use before "
|
|
1367
|
+
"pushary_ask_end_user for a user who has not connected a phone yet."
|
|
1368
|
+
),
|
|
1369
|
+
"parameters": {
|
|
1370
|
+
"type": "object",
|
|
1371
|
+
"properties": {
|
|
1372
|
+
"external_id": {
|
|
1373
|
+
"type": "string",
|
|
1374
|
+
"description": "Your own stable id for the end-user (a user id, tenant key, email hash).",
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
"required": ["external_id"],
|
|
1378
|
+
},
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
PUSHARY_ASK_END_USER = {
|
|
1382
|
+
"name": "pushary_ask_end_user",
|
|
1383
|
+
"description": (
|
|
1384
|
+
"Ask one of your OWN end-users a question and block until they answer on "
|
|
1385
|
+
"their phone (Partner plan). Durable and fail-closed: a decline, a timeout, "
|
|
1386
|
+
"or no answer comes back as approved=false, so silence never turns into "
|
|
1387
|
+
"consent. Connect the person first with pushary_enroll. In gateway "
|
|
1388
|
+
"(multi-user) mode, pass each user's external_id from your own user model, "
|
|
1389
|
+
"not the Hermes session id."
|
|
1390
|
+
),
|
|
1391
|
+
"parameters": {
|
|
1392
|
+
"type": "object",
|
|
1393
|
+
"properties": {
|
|
1394
|
+
"question": {
|
|
1395
|
+
"type": "string",
|
|
1396
|
+
"description": "The question to ask (max 8000 chars).",
|
|
1397
|
+
},
|
|
1398
|
+
"external_id": {
|
|
1399
|
+
"type": "string",
|
|
1400
|
+
"description": "Your own id for the end-user who should answer.",
|
|
1401
|
+
},
|
|
1402
|
+
"type": {
|
|
1403
|
+
"type": "string",
|
|
1404
|
+
"enum": ["confirm", "select", "input"],
|
|
1405
|
+
"description": "confirm (yes/no), select (pick from options), input (free text).",
|
|
1406
|
+
"default": "confirm",
|
|
1407
|
+
},
|
|
1408
|
+
"options": {
|
|
1409
|
+
"type": "array",
|
|
1410
|
+
"items": {"type": "string"},
|
|
1411
|
+
"description": "Choices for a select question (2-6 options).",
|
|
1412
|
+
},
|
|
1413
|
+
"context": {
|
|
1414
|
+
"type": "string",
|
|
1415
|
+
"description": "What you are working on, shown above the question.",
|
|
1416
|
+
},
|
|
1417
|
+
"agent_name": {
|
|
1418
|
+
"type": "string",
|
|
1419
|
+
"description": 'Identifies this Hermes instance (e.g., "Hermes - support").',
|
|
1420
|
+
},
|
|
1421
|
+
"timeout_seconds": {
|
|
1422
|
+
"type": "integer",
|
|
1423
|
+
"description": "How long to block waiting for the answer (default 50, the decision stays open longer).",
|
|
1424
|
+
"default": 50,
|
|
1425
|
+
},
|
|
1426
|
+
},
|
|
1427
|
+
"required": ["question", "external_id"],
|
|
1428
|
+
},
|
|
1429
|
+
}
|
|
1430
|
+
` },
|
|
1431
|
+
{ path: "skills/pushary/SKILL.md", contents: '---\nname: pushary-hermes\nversion: 0.9.0\ndescription: Push notifications and human-in-the-loop for Hermes Agent. Use this whenever a running agent needs a human and no chat session is active, such as before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill\'s workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Send alerts when tasks finish, ask questions (yes/no, multiple choice, or free text) via web push, and get answers from their connected devices. Use these tools proactively when the user is not actively in a chat session. Works alongside Hermes\'s built-in messaging platforms (Telegram, Discord, etc.) as a universal fallback channel.\nmetadata:\n hermes:\n tags: [notifications, push, human-in-the-loop, alerts, permissions]\n category: productivity\n requires_toolsets: []\n config:\n - key: PUSHARY_API_KEY\n description: "Your Pushary API key for push notifications"\n default: ""\n tags: notifications, push, mcp, human-in-the-loop, hermes, alerts, permissions\n---\n\n# Pushary - Push Notifications for Hermes Agent\n\nPushary adds mobile, Mac and browser delivery for Hermes. Use it when the user is not actively monitoring a chat platform, or when you need to reach them on their phone\'s lock screen for a time-sensitive decision.\n\n## Ask in as Few Interruptions as Possible\n\nHonor authorization already granted in this session. Ask only for a missing decision or an action outside that authorization, or when an enforced host policy requires it. A multi-step task alone does not require plan approval. Never ask again merely because the next authorized step deletes, deploys or publishes something. These skills guide the agent; supported hooks and runtime approval gates enforce policy. Do not bypass an enforced gate.\n\nEvery question costs the user their attention wherever they are. Before a run of more than a step or two, work out where you will need a human and fold those points together: one `select` carrying the real options beats three `confirm`s in a row, ask once at a boundary rather than once per instance, and never ask what you can determine yourself from the task or from a tool call you can make.\n\n## When to Use Pushary vs Hermes Platforms\n\n**Use Pushary when:**\n- The user has no active chat session (Telegram, Discord, etc.)\n- You need to reach the user\'s phone lock screen for a quick decision\n- A background task finishes and the user may have walked away\n- Permission escalation - a dangerous command needs approval\n- Another skill\'s workflow says to confirm with the user, and no chat session is active to confirm in\n- The user explicitly asked for push notifications\n\n**Use the active Hermes platform when:**\n- The user is currently in a Telegram/Discord/Slack conversation with you\n- The question is part of an ongoing dialog\n- The user prefers responses in their current platform\n\nUse an extra push only when the user requested it or a meaningful unattended result needs attention. Do not assume you can detect whether a Telegram or Discord message was read.\n\n## Setup\n\nOn a Mac, the Pushary app connects Hermes: it puts this plugin in `~/.hermes/plugins/pushary` and selects Pushary as the approval transport. On any computer, `npx pushary@latest setup --agents hermes` does the same. The plugin can also come from the Hermes catalog with `hermes plugins install pushary`. Never install a second copy over one that is already there. No MCP server configuration is needed. Keep credentials out of chat and verify the paired account and device.\n\nIf the `pushary_*` tools are missing from a Hermes session, the plugin did not load. Do not pip-install it into Hermes\'s Python: current Hermes does not load packages added that way. Ask the user to open the Pushary app and repair Hermes, or to run `npx pushary@latest setup --agents hermes`. Then they restart Hermes with `hermes gateway restart`.\n\n## Approvals Go to the Phone\n\nThe plugin registers `pushary` as a Hermes **approval transport**, so the dangerous-command approvals Hermes already asks for open in the app with the same four choices the terminal offers: allow once, allow for this session, always allow, deny. Hermes owns the timeout (300 seconds by default) and remembers session and always decisions exactly as it would have.\n\n```yaml\nsecurity:\n approval:\n transport: pushary\n transport_fallback: builtin\n```\n\nHermes denies by default when Pushary is unreachable. Only an operator who sets `transport_fallback: builtin` enables its built-in prompt after a safe local handoff; cancellation or an unverifiable withdrawal still stops the action. You do not call this yourself; it fires when Hermes decides a command needs a human.\n\n## Answer surfaces and account boundaries\n\n| Surface | What the user can do |\n| --- | --- |\n| Mobile app | Answer confirm, select and input questions. Supported confirm notifications offer approve/deny actions on the lock screen; arbitrary choices and text open the app. |\n| Mac notch | Answer personal account questions with confirm, select, input and question-set controls, including keyboard controls. Presence and delivery policy determine when the phone is also reached. |\n| Slack | Answer through buttons, menus or text modals when the integration and intended recipient are configured. |\n| Browser | Open the decision page as a fallback; browser notification delivery requires permission. |\n\nPersonal setup connects the operator\'s devices. For a Mac, the operator installs from https://pushary.com/download, signs in to the same personal account and connects their agents in the app. Request one harmless test question and verify it reaches the intended surface. Test phone fallback while away from the Mac; do not infer delivery from a successful API call alone.\n\nPartner customers use scoped enrollment links issued by their application. Do not enroll them into the operator\'s account or send their decisions through personal tools. The Mac notch currently uses the personal account/session API; do not promise a Partner customer inbox on Mac. See https://pushary.com/docs/agents/embed for Partner setup.\n\n## Tools\n\nTool names below are the native plugin names. An MCP client without the plugin sees the same capabilities as `send_notification`, `schedule_reminder`, `ask_user`, `wait_for_answer`, `cancel_question`, and `propose_scope`.\n\n### pushary_remind\n\nFor personal reminders later, use `pushary_remind` with `body` and `in_minutes`, or `at` with an explicit UTC offset. Never guess the timezone; confirm the returned time. Pushary saves it and delivers after Hermes exits. Use Hermes cron for future agent work. No arguments lists pending reminders; `cancel_reminder_id` cancels a scheduled one. One-time only, up to 30 days ahead, and delivery can be about a minute late. A connected phone is required. Do not use `pushary_ask` for reminders.\n\n### pushary_notify\n\n\nSend a one-way push notification. Optionally include structured context for a rich detail page.\n\n**Parameters:**\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| title | string | Yes | Notification title (max 100 chars, aim for under 60) |\n| body | string | Yes | Notification body (max 500 chars, aim for under 200) |\n| agent_name | string | No | Identifies this Hermes instance (e.g., "Hermes - daily-briefing") |\n| context_type | "task_complete" / "error" / "info" | Yes for task updates | Marks the notification for task-update routing. |\n| summary | string | No | Short summary of the result. |\n| details / files_changed | string[] | No | Result details or changed paths. |\n| error_message / next_steps | string | No | Error or suggested follow-up. |\n\n**Example - cron task completed:**\n\n```json\n{\n "title": "Daily briefing ready",\n "body": "Compiled 12 news items and 3 calendar events",\n "agent_name": "Hermes - daily-briefing",\n "context_type": "task_complete",\n "summary": "Morning briefing compiled from RSS feeds and Google Calendar",\n "details": ["12 tech news items", "3 meetings today", "2 PRs awaiting review"],\n "next_steps": "Say \'read briefing\' in Telegram to hear the full summary"\n}\n```\n\n### pushary_ask\n\nAsk a question via push notification and **wait for the answer** (blocks by default). Three question types: confirm (yes/no), select (multiple choice), input (free text).\n\n**Parameters:**\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| question | string | Yes | The complete question (max 8000 chars; notifications show a short preview) |\n| type | "confirm" / "select" / "input" | No | Question type (default: confirm) |\n| options | string[] | No | Choices for select type (2-6 options) |\n| placeholder | string | No | Placeholder text for input type |\n| context | string | No | What you\'re working on, shown above the question |\n| agent_name | string | No | Identifies this Hermes instance |\n| wait | boolean | No | Wait for answer before returning (default: true) |\n| timeout_ms | integer | No | Max wait in ms (max 55000). Defaults to 30000 per poll attempt; server policy may hand off earlier. |\n\n**Returns:**\n- `{ "answered": true, "value": "yes" }` - user responded\n- `{ "answered": false, "timedOut": true }` - no response within timeout\n\n**Example - dangerous command approval:**\n\n```json\n{\n "question": "Allow: rm -rf /tmp/build-artifacts/*",\n "type": "confirm",\n "context": "Cleaning up 2.3GB of stale build artifacts from last week",\n "agent_name": "Hermes - server-maintenance",\n "timeout_ms": 12000\n}\n```\n\n### pushary_wait\n\nPoll once for a response when `pushary_ask` was called with `wait: false`. Not needed with default blocking mode.\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| correlation_id | string | Yes | The correlationId from pushary_ask |\n| timeout_ms | integer | No | How long to wait (default 30000, max 55000) |\n\n### pushary_cancel\n\nCancel a pending question that\'s no longer relevant.\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| correlation_id | string | Yes | The correlationId to cancel |\n\n### pushary_propose_scope\n\nAgree the boundary of a multi-step run in one tap, before doing the work, instead of asking file by file. Use it once when a boundary needs agreement or the user requests an enforced scope. Skip a redundant proposal for already authorized work.\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| done_when | string | Yes | What "finished" means for this run |\n| allowed_paths | string[] | No | Globs you intend to change, e.g. `["src/**"]` |\n| off_limits_paths | string[] | No | Globs you promise not to touch; these win on overlap |\n| promises | string[] | No | Boundaries that are not file paths: recipients, channels, spend, systems you will not open |\n| agent_name | string | No | Identifies this Hermes instance |\n\nOnly file paths are enforced, and only on tool calls that carry one. Shell commands, reads, web requests and MCP tools carry no path, so the contract says nothing about them. A run that changes no files belongs in `promises`, which is shown to the user and recorded but never checked. A word that is not a path in `allowed_paths` matches no file and makes every change ask instead.\n\nRead `enforces` in the result. An empty list means nothing here is checked automatically, and you must say so rather than report that a scope is in force.\n\nReturns `ratified: true` only on an explicit yes. Anything else means proceed as if no scope was agreed; do not describe it as ratified.\n\n## Human-in-the-Loop Flow\n\n`pushary_ask` performs its initial wait and at most one server-directed poll within that tool call. If the returned status is still pending, continue with `pushary_wait` and the same correlationId. With `wait: false`, start that polling yourself. Follow an explicit handoff instead when one is returned.\n\nRead `answered`, `status` and `handoffAction` (falling back to `nextAction`) on every response. Only `pending` is live; expired, cancelled, missing and unavailable are not new timeouts. Keep waiting with the same correlationId while pending. A poll ending does not expire the question; never cancel just because a poll returned without an answer. Before moving a live question to the current chat, cancel it. If cancellation says `stop`, stop; if it loses a race, poll once for one second and honor the winning answer. Silence is never consent. A select or input value containing \u201Cyes\u201D is answer data, not approval of a separate action.\n\nDelivery is controlled by the user\'s policy: `push_first` uses presence, `push_only` requests push every time, `notify_only` leaves the decision in the current client, and `terminal_only` avoids push. Do not override the mode or duplicate a question on every surface. The runtime owns delivery, expiry and settlement; do not claim that a reply can restart an ended agent turn.\n\nEach call returns an answer, a pending wait, or an explicit handoff:\n\n```\nresult = pushary_ask({\n question: "Deploy the updated config to production?",\n type: "confirm",\n context: "nginx config updated with new rate limits",\n tool_name: "terminal",\n tool_target: "systemctl reload",\n agent_name: "Hermes - devops"\n})\n\nif result.answered:\n if result.value == "yes":\n // proceed with deployment\n else:\n // abort and notify via active platform\nelse:\n // follow handoffAction when present, otherwise nextAction\n```\n\nPass `tool_name` and `tool_target` whenever the question is about a specific operation. They are what let the user turn a repeated approval into a standing rule, and what group the decision in their ledger.\n\n## Identifying Your Instance\n\nAlways pass `agent_name` so the user knows which Hermes profile or task is asking.\n\n**Format:** `"Hermes - {profile or task}"`\n\n**Examples:**\n- `"Hermes - daily-briefing"`\n- `"Hermes - server-maintenance"`\n- `"Hermes - code-review"`\n- `"Hermes - personal-assistant"`\n\n## Notification Etiquette\n\n- **Titles under 60 characters.** Phone lock screens truncate aggressively.\n- **Bodies under 200 characters.** Use the flat `summary`, `details` and `next_steps` fields.\n- **Max 3 push notifications per task.** If the user is in an active chat, prefer that channel.\n- **Don\'t duplicate.** If you already sent the message via Telegram/Discord, send another push only if requested or needed for an unattended result.\n' },
|
|
1432
|
+
{ path: "tools.py", contents: `import json
|
|
1433
|
+
import time
|
|
1434
|
+
import uuid
|
|
1435
|
+
from . import api, identity
|
|
1436
|
+
|
|
1437
|
+
CONTEXT_FIELDS = {
|
|
1438
|
+
"summary": "summary",
|
|
1439
|
+
"details": "details",
|
|
1440
|
+
"files_changed": "filesChanged",
|
|
1441
|
+
"error_message": "errorMessage",
|
|
1442
|
+
"next_steps": "nextSteps",
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _accepted_fields(params, accepted):
|
|
1447
|
+
return {key: params[key] for key in accepted if params.get(key) is not None}
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def pushary_notify(params, **kwargs):
|
|
1451
|
+
title = params.get("title", "")
|
|
1452
|
+
body = params.get("body", "")
|
|
1453
|
+
agent_name = identity.labelled_agent_name(params.get("agent_name"))
|
|
1454
|
+
|
|
1455
|
+
context = None
|
|
1456
|
+
context_type = params.get("context_type")
|
|
1457
|
+
if context_type:
|
|
1458
|
+
context = {"type": context_type}
|
|
1459
|
+
for field, camel in CONTEXT_FIELDS.items():
|
|
1460
|
+
value = params.get(field)
|
|
1461
|
+
if value is not None:
|
|
1462
|
+
context[camel] = value
|
|
1463
|
+
|
|
1464
|
+
try:
|
|
1465
|
+
result = api.send_notification(
|
|
1466
|
+
title,
|
|
1467
|
+
body,
|
|
1468
|
+
agent_name=agent_name,
|
|
1469
|
+
context=context,
|
|
1470
|
+
**_accepted_fields(params, api.IDENTITY_PARAM_NAMES),
|
|
1471
|
+
)
|
|
1472
|
+
return json.dumps(result)
|
|
1473
|
+
except Exception as e:
|
|
1474
|
+
return json.dumps({"error": str(e)})
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def pushary_remind(params, **kwargs):
|
|
1478
|
+
try:
|
|
1479
|
+
result = api.schedule_reminder(
|
|
1480
|
+
agent_name=identity.labelled_agent_name(params.get("agent_name")),
|
|
1481
|
+
**_accepted_fields(params, ("body", "title", "in_minutes", "at", "cancel_reminder_id", "request_id", "reminder_id", "env", *api.IDENTITY_PARAM_NAMES)),
|
|
1482
|
+
)
|
|
1483
|
+
return json.dumps(result)
|
|
1484
|
+
except Exception as exc:
|
|
1485
|
+
return json.dumps({"error": str(exc)})
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def pushary_ask(params, **kwargs):
|
|
1489
|
+
question = params.get("question", "")
|
|
1490
|
+
question_type = params.get("type", "confirm")
|
|
1491
|
+
options = params.get("options")
|
|
1492
|
+
placeholder = params.get("placeholder")
|
|
1493
|
+
context = params.get("context")
|
|
1494
|
+
agent_name = identity.labelled_agent_name(params.get("agent_name"))
|
|
1495
|
+
wait = params.get("wait", True)
|
|
1496
|
+
timeout_ms = params.get("timeout_ms", 30000)
|
|
1497
|
+
|
|
1498
|
+
try:
|
|
1499
|
+
result = api.ask_user(
|
|
1500
|
+
question,
|
|
1501
|
+
question_type=question_type,
|
|
1502
|
+
options=options,
|
|
1503
|
+
placeholder=placeholder,
|
|
1504
|
+
context=context,
|
|
1505
|
+
agent_name=agent_name,
|
|
1506
|
+
wait=wait,
|
|
1507
|
+
timeout_ms=timeout_ms,
|
|
1508
|
+
**_accepted_fields(params, api.DECISION_PARAM_NAMES),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
next_action = result.get("handoffAction") or result.get("nextAction")
|
|
1512
|
+
if (
|
|
1513
|
+
not wait
|
|
1514
|
+
or "error" in result
|
|
1515
|
+
or result.get("answered")
|
|
1516
|
+
or (next_action and next_action != "wait_for_answer")
|
|
1517
|
+
):
|
|
1518
|
+
return json.dumps(result)
|
|
1519
|
+
|
|
1520
|
+
correlation_id = result.get("correlationId")
|
|
1521
|
+
if not correlation_id:
|
|
1522
|
+
return json.dumps(result)
|
|
1523
|
+
|
|
1524
|
+
answer = api.wait_for_answer(correlation_id, timeout_ms)
|
|
1525
|
+
return json.dumps({
|
|
1526
|
+
"correlationId": correlation_id,
|
|
1527
|
+
"question": question,
|
|
1528
|
+
"type": question_type,
|
|
1529
|
+
**answer,
|
|
1530
|
+
})
|
|
1531
|
+
except Exception as e:
|
|
1532
|
+
return json.dumps({"error": str(e)})
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def pushary_wait(params, **kwargs):
|
|
1536
|
+
correlation_id = params.get("correlation_id", "")
|
|
1537
|
+
timeout_ms = params.get("timeout_ms", 30000)
|
|
1538
|
+
|
|
1539
|
+
try:
|
|
1540
|
+
result = api.wait_for_answer(correlation_id, timeout_ms)
|
|
1541
|
+
return json.dumps(result)
|
|
1542
|
+
except Exception as e:
|
|
1543
|
+
return json.dumps({"error": str(e)})
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def pushary_cancel(params, **kwargs):
|
|
1547
|
+
correlation_id = params.get("correlation_id", "")
|
|
1548
|
+
|
|
1549
|
+
try:
|
|
1550
|
+
result = api.cancel_question(correlation_id)
|
|
1551
|
+
return json.dumps(result)
|
|
1552
|
+
except Exception as e:
|
|
1553
|
+
return json.dumps({"error": str(e)})
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def pushary_propose_scope(params, **kwargs):
|
|
1557
|
+
done_when = params.get("done_when", "")
|
|
1558
|
+
if not done_when:
|
|
1559
|
+
return json.dumps({"error": "done_when is required"})
|
|
1560
|
+
|
|
1561
|
+
session = params.get("session_id") or identity.session_id()
|
|
1562
|
+
if not session:
|
|
1563
|
+
return json.dumps({
|
|
1564
|
+
"error": "No session id is available yet, so a scope contract could not be "
|
|
1565
|
+
"enforced. Proceed without one and ask before each risky step.",
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
try:
|
|
1569
|
+
result = api.propose_scope(
|
|
1570
|
+
done_when,
|
|
1571
|
+
session,
|
|
1572
|
+
allowed_paths=params.get("allowed_paths"),
|
|
1573
|
+
off_limits_paths=params.get("off_limits_paths"),
|
|
1574
|
+
promises=params.get("promises"),
|
|
1575
|
+
agent_name=identity.labelled_agent_name(params.get("agent_name")),
|
|
1576
|
+
timeout_ms=params.get("timeout_ms"),
|
|
1577
|
+
)
|
|
1578
|
+
return json.dumps(result)
|
|
1579
|
+
except Exception as e:
|
|
1580
|
+
return json.dumps({"error": str(e)})
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def pushary_enroll(params, **kwargs):
|
|
1584
|
+
"""Partner tool: connect one of your OWN end-users' phones (keyless, one tap)."""
|
|
1585
|
+
external_id = params.get("external_id", "")
|
|
1586
|
+
if not external_id:
|
|
1587
|
+
return json.dumps({"error": "external_id is required"})
|
|
1588
|
+
|
|
1589
|
+
try:
|
|
1590
|
+
result = api.enroll_end_user(external_id)
|
|
1591
|
+
if "error" in result:
|
|
1592
|
+
return json.dumps(result)
|
|
1593
|
+
return json.dumps({
|
|
1594
|
+
"externalId": result.get("externalId", external_id),
|
|
1595
|
+
"connectLink": result.get("universalLink"),
|
|
1596
|
+
"expiresInSeconds": result.get("expiresInSeconds"),
|
|
1597
|
+
"hint": (
|
|
1598
|
+
"Show connectLink to this end-user. One tap connects their phone for "
|
|
1599
|
+
"approvals. The link is single-use and expires, so cache the enrollment, "
|
|
1600
|
+
"not the link."
|
|
1601
|
+
),
|
|
1602
|
+
})
|
|
1603
|
+
except Exception as e:
|
|
1604
|
+
return json.dumps({"error": str(e)})
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def pushary_ask_end_user(params, **kwargs):
|
|
1608
|
+
"""Partner tool: ask one of your OWN end-users and block on a fail-closed answer.
|
|
1609
|
+
|
|
1610
|
+
Opens a durable decision addressed to external_id, then long-polls the ledger
|
|
1611
|
+
until it resolves or the timeout passes. Fail-closed: only an answered,
|
|
1612
|
+
affirmative confirm returns approved=True.
|
|
1613
|
+
"""
|
|
1614
|
+
question = params.get("question", "")
|
|
1615
|
+
external_id = params.get("external_id", "")
|
|
1616
|
+
question_type = params.get("type", "confirm")
|
|
1617
|
+
options = params.get("options")
|
|
1618
|
+
context = params.get("context")
|
|
1619
|
+
agent_name = params.get("agent_name")
|
|
1620
|
+
try:
|
|
1621
|
+
timeout_seconds = max(1, int(params.get("timeout_seconds", 50)))
|
|
1622
|
+
except (TypeError, ValueError):
|
|
1623
|
+
timeout_seconds = 50
|
|
1624
|
+
|
|
1625
|
+
if not question or not external_id:
|
|
1626
|
+
return json.dumps({"error": "question and external_id are required"})
|
|
1627
|
+
|
|
1628
|
+
try:
|
|
1629
|
+
created = api.create_end_user_decision(
|
|
1630
|
+
question,
|
|
1631
|
+
external_id,
|
|
1632
|
+
question_type=question_type,
|
|
1633
|
+
options=options,
|
|
1634
|
+
agent_name=agent_name,
|
|
1635
|
+
context=context,
|
|
1636
|
+
# One tool call = one intended ask. A fresh key per invocation means two
|
|
1637
|
+
# identical-text asks never collapse into one silent auto-approval.
|
|
1638
|
+
idempotency_key=uuid.uuid4().hex,
|
|
1639
|
+
)
|
|
1640
|
+
if "error" in created:
|
|
1641
|
+
return json.dumps(created)
|
|
1642
|
+
|
|
1643
|
+
decision_id = created.get("decisionId")
|
|
1644
|
+
if not decision_id:
|
|
1645
|
+
return json.dumps({"error": "Pushary did not return a decisionId", "raw": created})
|
|
1646
|
+
|
|
1647
|
+
status = created.get("status", "pending")
|
|
1648
|
+
value = created.get("value")
|
|
1649
|
+
|
|
1650
|
+
deadline = time.monotonic() + timeout_seconds
|
|
1651
|
+
while status == "pending" and time.monotonic() < deadline:
|
|
1652
|
+
remaining = deadline - time.monotonic()
|
|
1653
|
+
wait = max(1, min(45, int(remaining) + 1))
|
|
1654
|
+
polled = api.get_end_user_decision(decision_id, wait_seconds=wait)
|
|
1655
|
+
if "error" in polled:
|
|
1656
|
+
break
|
|
1657
|
+
status = polled.get("status", status)
|
|
1658
|
+
value = polled.get("value")
|
|
1659
|
+
|
|
1660
|
+
answered = status == "answered"
|
|
1661
|
+
approved = answered and question_type == "confirm" and api.is_affirmative(value)
|
|
1662
|
+
return json.dumps({
|
|
1663
|
+
"decisionId": decision_id,
|
|
1664
|
+
"externalId": external_id,
|
|
1665
|
+
"status": status,
|
|
1666
|
+
"answered": answered,
|
|
1667
|
+
"approved": approved,
|
|
1668
|
+
"value": value,
|
|
1669
|
+
})
|
|
1670
|
+
except Exception as e:
|
|
1671
|
+
return json.dumps({"error": str(e)})
|
|
1672
|
+
` }
|
|
1673
|
+
];
|
|
1674
|
+
|
|
1675
|
+
// src/hermes/plugin-payload.ts
|
|
1676
|
+
var HERMES_PLUGIN_KEY = "pushary";
|
|
1677
|
+
var HERMES_MANIFEST_FILE = "plugin.yaml";
|
|
1678
|
+
var HERMES_INSTALL_MARKER_FILE = ".pushary-install.json";
|
|
1679
|
+
var HERMES_INSTALL_MARKER_OWNER = "pushary";
|
|
1680
|
+
var HERMES_LOAD_RECORDS_PATH = "plugin-data/pushary/loaded";
|
|
1681
|
+
var HERMES_LEGACY_PIP_PACKAGE = "hermes-plugin-pushary";
|
|
1682
|
+
var HERMES_QUESTION_TOOLSET = "clarify";
|
|
1683
|
+
var HERMES_APPROVAL_TRANSPORT_KEY = "security.approval.transport";
|
|
1684
|
+
var HERMES_APPROVAL_FALLBACK_KEY = "security.approval.transport_fallback";
|
|
1685
|
+
var HERMES_PLUGINS_ENABLED_KEY = "plugins.enabled";
|
|
1686
|
+
var HERMES_DISABLED_TOOLSETS_KEY = "agent.disabled_toolsets";
|
|
1687
|
+
var HERMES_PUSHARY_TRANSPORT = "pushary";
|
|
1688
|
+
var HERMES_PUSHARY_FALLBACK = "builtin";
|
|
1689
|
+
var HERMES_PUSHARY_SETTINGS = [
|
|
1690
|
+
{ key: HERMES_APPROVAL_TRANSPORT_KEY, value: HERMES_PUSHARY_TRANSPORT },
|
|
1691
|
+
{ key: HERMES_APPROVAL_FALLBACK_KEY, value: HERMES_PUSHARY_FALLBACK }
|
|
1692
|
+
];
|
|
1693
|
+
var unquoted = (value) => value.trim().replace(/^['"]|['"]$/g, "");
|
|
1694
|
+
var manifestVersion = (manifest) => {
|
|
1695
|
+
for (const line of manifest.split("\n")) {
|
|
1696
|
+
const separator = line.indexOf(":");
|
|
1697
|
+
if (separator < 0 || line.slice(0, separator).trim() !== "version") continue;
|
|
1698
|
+
return unquoted(line.slice(separator + 1)) || null;
|
|
1699
|
+
}
|
|
1700
|
+
return null;
|
|
1701
|
+
};
|
|
1702
|
+
var hermesPluginVersion = (files) => {
|
|
1703
|
+
const manifest = files.find((file) => file.path === HERMES_MANIFEST_FILE);
|
|
1704
|
+
return manifest ? manifestVersion(manifest.contents) : null;
|
|
1705
|
+
};
|
|
1706
|
+
var hermesPluginDigest = (files) => {
|
|
1707
|
+
const hash = createHash("sha256");
|
|
1708
|
+
for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
1709
|
+
hash.update(file.path).update("\0").update(file.contents).update("\0");
|
|
1710
|
+
}
|
|
1711
|
+
return `sha256:${hash.digest("hex")}`;
|
|
1712
|
+
};
|
|
1713
|
+
var shippedHermesPlugin = (files = HERMES_PLUGIN_FILES) => {
|
|
1714
|
+
const version = hermesPluginVersion(files);
|
|
1715
|
+
if (!version) throw new Error(`The bundled Hermes plugin has no version in ${HERMES_MANIFEST_FILE}`);
|
|
1716
|
+
return { files, version, digest: hermesPluginDigest(files) };
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
// src/hermes/runtime.ts
|
|
1720
|
+
import { execFileSync } from "child_process";
|
|
1721
|
+
import { accessSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
1722
|
+
import { homedir } from "os";
|
|
1723
|
+
import { basename, dirname, isAbsolute, join } from "path";
|
|
1724
|
+
var HERMES_COMMAND_TIMEOUT_MS = 2e4;
|
|
1725
|
+
var LEGACY_PIP_TIMEOUT_MS = 6e4;
|
|
1726
|
+
var HERMES_SETTING_NOT_SET = "Config key not set";
|
|
1727
|
+
var HERMES_PLUGINS_SECTION = "plugins";
|
|
1728
|
+
var HERMES_APPROVAL_SECTION = "security.approval";
|
|
1729
|
+
var INSTALL_METADATA_FILE = ".install-metadata.json";
|
|
1730
|
+
var GATEWAY_STATE_FILE = "gateway_state.json";
|
|
1731
|
+
var GATEWAY_PID_FILE = "gateway.pid";
|
|
1732
|
+
var GATEWAY_RUNNING = "running";
|
|
1733
|
+
var HERMES_LAUNCHER_NAME = "hermes";
|
|
1734
|
+
var HERMES_LAUNCHER_DIRECTORIES = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
1735
|
+
var isExecutableFile = (path) => {
|
|
1736
|
+
try {
|
|
1737
|
+
accessSync(path, constants.X_OK);
|
|
1738
|
+
return statSync(path).isFile();
|
|
1739
|
+
} catch {
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
var hermesLauncherCandidates = (userHome) => [join(userHome, ".local", "bin"), ...HERMES_LAUNCHER_DIRECTORIES].map((directory) => join(directory, HERMES_LAUNCHER_NAME));
|
|
1744
|
+
var findHermesLauncher = () => {
|
|
1745
|
+
const onPath = resolvePathBinary(HERMES_LAUNCHER_NAME);
|
|
1746
|
+
if (onPath) return { path: onPath };
|
|
1747
|
+
if (process.platform === "win32") return null;
|
|
1748
|
+
const installed = hermesLauncherCandidates(homedir()).find(isExecutableFile);
|
|
1749
|
+
return installed ? { path: installed } : null;
|
|
1750
|
+
};
|
|
1751
|
+
var quotedForWindowsShell = (argument) => `"${argument.replace(/"/g, '\\"')}"`;
|
|
1752
|
+
var commandOutput = (error) => {
|
|
1753
|
+
if (!isRecord(error)) return String(error);
|
|
1754
|
+
return [error.stdout, error.stderr].map((part) => part === void 0 || part === null ? "" : String(part)).join("\n");
|
|
1755
|
+
};
|
|
1756
|
+
var runHermesCommand = (launcher, args) => {
|
|
1757
|
+
const shell = needsShell(launcher.path);
|
|
1758
|
+
try {
|
|
1759
|
+
const stdout = execFileSync(
|
|
1760
|
+
shell ? quotedForWindowsShell(launcher.path) : launcher.path,
|
|
1761
|
+
shell ? args.map(quotedForWindowsShell) : [...args],
|
|
1762
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: HERMES_COMMAND_TIMEOUT_MS, shell }
|
|
1763
|
+
);
|
|
1764
|
+
return { kind: "ok", stdout };
|
|
1765
|
+
} catch (error) {
|
|
1766
|
+
return { kind: "failed", output: commandOutput(error) };
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
var lastLine = (output) => output.trim().split(/\r?\n/).filter((line) => line.trim().length > 0).at(-1)?.trim() ?? "";
|
|
1770
|
+
var readHermesHome = (launcher) => {
|
|
1771
|
+
const result = runHermesCommand(launcher, ["config", "path"]);
|
|
1772
|
+
if (result.kind !== "ok") return null;
|
|
1773
|
+
const configPath = lastLine(result.stdout);
|
|
1774
|
+
return isAbsolute(configPath) ? dirname(configPath) : null;
|
|
1775
|
+
};
|
|
1776
|
+
var parsedJson = (text) => {
|
|
1777
|
+
try {
|
|
1778
|
+
return { ok: true, value: JSON.parse(text) };
|
|
1779
|
+
} catch {
|
|
1780
|
+
return { ok: false };
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
var readHermesSetting = (launcher, key) => {
|
|
1784
|
+
const result = runHermesCommand(launcher, ["config", "get", key, "--json"]);
|
|
1785
|
+
if (result.kind === "failed") return result.output.includes(HERMES_SETTING_NOT_SET) ? { kind: "unset" } : null;
|
|
1786
|
+
const parsed = parsedJson(lastLine(result.stdout));
|
|
1787
|
+
return parsed.ok ? { kind: "set", value: parsed.value } : null;
|
|
1788
|
+
};
|
|
1789
|
+
var settingArgument = (value) => typeof value === "string" ? value : JSON.stringify(value);
|
|
1790
|
+
var writeHermesSetting = (launcher, key, value) => runHermesCommand(launcher, ["config", "set", key, settingArgument(value)]).kind === "ok";
|
|
1791
|
+
var unsetHermesSetting = (launcher, key) => {
|
|
1792
|
+
const result = runHermesCommand(launcher, ["config", "unset", key]);
|
|
1793
|
+
return result.kind === "ok" || result.output.includes(HERMES_SETTING_NOT_SET);
|
|
1794
|
+
};
|
|
1795
|
+
var enableHermesPlugin = (launcher) => runHermesCommand(launcher, ["plugins", "enable", HERMES_PLUGIN_KEY]).kind === "ok";
|
|
1796
|
+
var disableHermesPlugin = (launcher) => runHermesCommand(launcher, ["plugins", "disable", HERMES_PLUGIN_KEY]).kind === "ok";
|
|
1797
|
+
var removeHermesPlugin = (launcher) => runHermesCommand(launcher, ["plugins", "remove", HERMES_PLUGIN_KEY]).kind === "ok";
|
|
1798
|
+
var stringList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
1799
|
+
var readingValue = (reading) => reading.kind === "set" ? reading.value : void 0;
|
|
1800
|
+
var stringOrNull = (value) => typeof value === "string" ? value : null;
|
|
1801
|
+
var readHermesSettings = (launcher) => {
|
|
1802
|
+
const plugins = readHermesSetting(launcher, HERMES_PLUGINS_SECTION);
|
|
1803
|
+
const approval = readHermesSetting(launcher, HERMES_APPROVAL_SECTION);
|
|
1804
|
+
const toolsets = readHermesSetting(launcher, HERMES_DISABLED_TOOLSETS_KEY);
|
|
1805
|
+
if (!plugins || !approval || !toolsets) return null;
|
|
1806
|
+
const pluginSection = readingValue(plugins);
|
|
1807
|
+
const approvalSection = readingValue(approval);
|
|
1808
|
+
const enabled = isRecord(pluginSection) ? stringList(pluginSection.enabled) : [];
|
|
1809
|
+
const disabled = isRecord(pluginSection) ? stringList(pluginSection.disabled) : [];
|
|
1810
|
+
return {
|
|
1811
|
+
pluginEnabled: enabled.includes(HERMES_PLUGIN_KEY),
|
|
1812
|
+
pluginDisabled: disabled.includes(HERMES_PLUGIN_KEY),
|
|
1813
|
+
approvalTransport: isRecord(approvalSection) ? stringOrNull(approvalSection.transport) : null,
|
|
1814
|
+
approvalFallback: isRecord(approvalSection) ? stringOrNull(approvalSection.transport_fallback) : null,
|
|
1815
|
+
disabledToolsets: stringList(readingValue(toolsets))
|
|
1816
|
+
};
|
|
1817
|
+
};
|
|
1818
|
+
var HERMES_SNAPSHOT_KEYS = [HERMES_APPROVAL_TRANSPORT_KEY, HERMES_APPROVAL_FALLBACK_KEY];
|
|
1819
|
+
var hermesPluginDirectory = (home) => join(home, "plugins", HERMES_PLUGIN_KEY);
|
|
1820
|
+
var HERMES_ROOT_DIRECTORY = ".hermes";
|
|
1821
|
+
var HERMES_CONFIG_FILE = "config.yaml";
|
|
1822
|
+
var HERMES_ACTIVE_PROFILE_FILE = "active_profile";
|
|
1823
|
+
var HERMES_PROFILES_DIRECTORY = "profiles";
|
|
1824
|
+
var HERMES_DEFAULT_PROFILE = "default";
|
|
1825
|
+
var HERMES_PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
1826
|
+
var BYTE_ORDER_MARK = /^\uFEFF/;
|
|
1827
|
+
var expandUserHome = (path, userHome) => path === "~" || path.startsWith("~/") ? join(userHome, path.slice(1)) : path;
|
|
1828
|
+
var activeProfileName = (raw) => {
|
|
1829
|
+
const name = raw?.replace(BYTE_ORDER_MARK, "").trim() ?? "";
|
|
1830
|
+
return HERMES_PROFILE_NAME.test(name) && name !== HERMES_DEFAULT_PROFILE ? name : null;
|
|
1831
|
+
};
|
|
1832
|
+
var resolveHermesHome = (facts) => {
|
|
1833
|
+
const configured = facts.configuredHome?.trim() ? expandUserHome(facts.configuredHome.trim(), facts.userHome) : null;
|
|
1834
|
+
if (configured && basename(dirname(configured)) === HERMES_PROFILES_DIRECTORY) return configured;
|
|
1835
|
+
const root = configured ?? join(facts.userHome, HERMES_ROOT_DIRECTORY);
|
|
1836
|
+
const profile = activeProfileName(facts.readActiveProfile(root));
|
|
1837
|
+
const profileHome = profile === null ? null : join(root, HERMES_PROFILES_DIRECTORY, profile);
|
|
1838
|
+
return profileHome !== null && facts.isDirectory(profileHome) ? profileHome : root;
|
|
1839
|
+
};
|
|
1840
|
+
var isDirectoryOnDisk = (path) => {
|
|
1841
|
+
try {
|
|
1842
|
+
return statSync(path).isDirectory();
|
|
1843
|
+
} catch {
|
|
1844
|
+
return false;
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
var defaultHermesHome = () => resolveHermesHome({
|
|
1848
|
+
configuredHome: process.env.HERMES_HOME ?? null,
|
|
1849
|
+
userHome: homedir(),
|
|
1850
|
+
readActiveProfile: (root) => readTextFile(join(root, HERMES_ACTIVE_PROFILE_FILE)),
|
|
1851
|
+
isDirectory: isDirectoryOnDisk
|
|
1852
|
+
});
|
|
1853
|
+
var readJsonFile = (path) => {
|
|
1854
|
+
try {
|
|
1855
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
1856
|
+
} catch {
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
var readTextFile = (path) => {
|
|
1861
|
+
try {
|
|
1862
|
+
return readFileSync(path, "utf-8");
|
|
1863
|
+
} catch {
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
var readHermesPluginFolderFacts = (home) => {
|
|
1868
|
+
const directory = hermesPluginDirectory(home);
|
|
1869
|
+
const metadata = readJsonFile(join(home, "plugins", INSTALL_METADATA_FILE));
|
|
1870
|
+
const manifest = readTextFile(join(directory, HERMES_MANIFEST_FILE));
|
|
1871
|
+
return {
|
|
1872
|
+
exists: existsSync(directory),
|
|
1873
|
+
marker: readJsonFile(join(directory, HERMES_INSTALL_MARKER_FILE)),
|
|
1874
|
+
hasGitDirectory: existsSync(join(directory, ".git")),
|
|
1875
|
+
listedByHermesInstaller: isRecord(metadata) && isRecord(metadata[HERMES_PLUGIN_KEY]),
|
|
1876
|
+
manifestVersion: manifest === null ? null : manifestVersion(manifest)
|
|
1877
|
+
};
|
|
1878
|
+
};
|
|
1879
|
+
var writeStagedFiles = (staging, files, marker) => {
|
|
1880
|
+
rmSync(staging, { recursive: true, force: true });
|
|
1881
|
+
for (const file of files) {
|
|
1882
|
+
const path = join(staging, ...file.path.split("/"));
|
|
1883
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1884
|
+
writeFileSync(path, file.contents);
|
|
1885
|
+
}
|
|
1886
|
+
writeFileSync(join(staging, HERMES_INSTALL_MARKER_FILE), `${JSON.stringify(marker, null, 2)}
|
|
1887
|
+
`);
|
|
1888
|
+
};
|
|
1889
|
+
var writeHermesPluginFolder = (home, files, marker) => {
|
|
1890
|
+
const target = hermesPluginDirectory(home);
|
|
1891
|
+
const staging = join(home, `.pushary-plugin-staging-${process.pid}`);
|
|
1892
|
+
const previous = join(home, `.pushary-plugin-previous-${process.pid}`);
|
|
1893
|
+
writeStagedFiles(staging, files, marker);
|
|
1894
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1895
|
+
rmSync(previous, { recursive: true, force: true });
|
|
1896
|
+
const hadTarget = existsSync(target);
|
|
1897
|
+
if (hadTarget) renameSync(target, previous);
|
|
1898
|
+
try {
|
|
1899
|
+
renameSync(staging, target);
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
if (hadTarget) renameSync(previous, target);
|
|
1902
|
+
rmSync(staging, { recursive: true, force: true });
|
|
1903
|
+
throw error;
|
|
1904
|
+
}
|
|
1905
|
+
rmSync(previous, { recursive: true, force: true });
|
|
1906
|
+
};
|
|
1907
|
+
var isLiveProcess = (pid) => {
|
|
1908
|
+
try {
|
|
1909
|
+
process.kill(pid, 0);
|
|
1910
|
+
return true;
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
return isRecord(error) && error.code === "EPERM";
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
var positivePid = (value) => typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
1916
|
+
var readHermesGateway = (home) => {
|
|
1917
|
+
const state = readJsonFile(join(home, GATEWAY_STATE_FILE));
|
|
1918
|
+
const pidFile = readJsonFile(join(home, GATEWAY_PID_FILE));
|
|
1919
|
+
const statePid = isRecord(state) && state.gateway_state === GATEWAY_RUNNING ? positivePid(state.pid) : null;
|
|
1920
|
+
const pid = statePid ?? (isRecord(state) ? null : isRecord(pidFile) ? positivePid(pidFile.pid) : null);
|
|
1921
|
+
return pid !== null && isLiveProcess(pid) ? { kind: "running", pid } : { kind: "not_running" };
|
|
1922
|
+
};
|
|
1923
|
+
var readHermesLoadRecord = (home, pid) => {
|
|
1924
|
+
const record = readJsonFile(join(home, ...HERMES_LOAD_RECORDS_PATH.split("/"), `${pid}.json`));
|
|
1925
|
+
if (!isRecord(record)) return null;
|
|
1926
|
+
return {
|
|
1927
|
+
pluginVersion: stringOrNull(record.pluginVersion),
|
|
1928
|
+
approvalTransport: record.approvalTransport === true
|
|
1929
|
+
};
|
|
1930
|
+
};
|
|
1931
|
+
var PYTHON_INTERPRETER = /python/i;
|
|
1932
|
+
var SHEBANG_READ_BYTES = 1024;
|
|
1933
|
+
var readFirstLine = (path) => {
|
|
1934
|
+
let descriptor = null;
|
|
1935
|
+
try {
|
|
1936
|
+
descriptor = openSync(path, "r");
|
|
1937
|
+
const buffer = Buffer.alloc(SHEBANG_READ_BYTES);
|
|
1938
|
+
const length = readSync(descriptor, buffer, 0, SHEBANG_READ_BYTES, 0);
|
|
1939
|
+
return buffer.subarray(0, length).toString("utf-8").split("\n", 1)[0] ?? "";
|
|
1940
|
+
} catch {
|
|
1941
|
+
return "";
|
|
1942
|
+
} finally {
|
|
1943
|
+
if (descriptor !== null) closeSync(descriptor);
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
var legacyHermesPython = (launcher) => {
|
|
1947
|
+
const firstLine = readFirstLine(launcher.path);
|
|
1948
|
+
if (!firstLine.startsWith("#!")) return null;
|
|
1949
|
+
const interpreter = firstLine.slice(2).trim().split(/\s+/)[0] ?? "";
|
|
1950
|
+
if (!PYTHON_INTERPRETER.test(interpreter)) return null;
|
|
1951
|
+
try {
|
|
1952
|
+
accessSync(interpreter, constants.X_OK);
|
|
1953
|
+
return interpreter;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
var removeLegacyPipCopy = (python, distribution) => {
|
|
1959
|
+
try {
|
|
1960
|
+
execFileSync(python, ["-m", "pip", "uninstall", "-y", distribution], { stdio: "ignore", timeout: LEGACY_PIP_TIMEOUT_MS });
|
|
1961
|
+
return true;
|
|
1962
|
+
} catch {
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
// src/hermes/wiring-state.ts
|
|
1968
|
+
var HERMES_INSTALLERS = ["cli", "mac_app"];
|
|
1969
|
+
var isHermesInstaller = (value) => HERMES_INSTALLERS.some((installer) => installer === value);
|
|
1970
|
+
var parseHermesInstallMarker = (value) => {
|
|
1971
|
+
if (!isRecord(value) || value.owner !== HERMES_INSTALL_MARKER_OWNER) return null;
|
|
1972
|
+
if (!isHermesInstaller(value.installedBy)) return null;
|
|
1973
|
+
if (typeof value.version !== "string" || typeof value.digest !== "string") return null;
|
|
1974
|
+
return {
|
|
1975
|
+
owner: HERMES_INSTALL_MARKER_OWNER,
|
|
1976
|
+
installedBy: value.installedBy,
|
|
1977
|
+
version: value.version,
|
|
1978
|
+
digest: value.digest,
|
|
1979
|
+
routeQuestions: value.routeQuestions === true
|
|
1980
|
+
};
|
|
1981
|
+
};
|
|
1982
|
+
var hermesInstallMarker = (installedBy, plugin) => ({
|
|
1983
|
+
owner: HERMES_INSTALL_MARKER_OWNER,
|
|
1984
|
+
installedBy,
|
|
1985
|
+
version: plugin.version,
|
|
1986
|
+
digest: plugin.digest,
|
|
1987
|
+
routeQuestions: true
|
|
1988
|
+
});
|
|
1989
|
+
var classifyHermesPluginFolder = (facts) => {
|
|
1990
|
+
if (!facts.exists) return { kind: "absent" };
|
|
1991
|
+
if (facts.hasGitDirectory || facts.listedByHermesInstaller) {
|
|
1992
|
+
return { kind: "hermes_installer", version: facts.manifestVersion };
|
|
1993
|
+
}
|
|
1994
|
+
const marker = parseHermesInstallMarker(facts.marker);
|
|
1995
|
+
if (marker) return { kind: "pushary", marker, version: facts.manifestVersion };
|
|
1996
|
+
return { kind: "unknown", version: facts.manifestVersion };
|
|
1997
|
+
};
|
|
1998
|
+
var replacesInstalledRelease = (bundled, installed) => {
|
|
1999
|
+
if (!isReleaseVersion(bundled.version) || !isReleaseVersion(installed.version)) return false;
|
|
2000
|
+
if (isNewerVersion(bundled.version, installed.version)) return true;
|
|
2001
|
+
return bundled.version === installed.version && bundled.digest !== installed.digest;
|
|
2002
|
+
};
|
|
2003
|
+
var bundledPluginReplaces = (folder, bundled) => {
|
|
2004
|
+
if (folder.kind === "absent") return true;
|
|
2005
|
+
return folder.kind === "pushary" && replacesInstalledRelease(bundled, folder.marker);
|
|
2006
|
+
};
|
|
2007
|
+
var hermesWiringOwner = (folder, macAppRecordExists) => {
|
|
2008
|
+
if (folder.kind === "pushary") return folder.marker.installedBy;
|
|
2009
|
+
return macAppRecordExists ? "mac_app" : null;
|
|
2010
|
+
};
|
|
2011
|
+
var routesHermesApprovals = (settings) => settings.approvalTransport === HERMES_PUSHARY_TRANSPORT;
|
|
2012
|
+
var routesHermesQuestions = (folder) => folder.kind === "pushary" && folder.marker.routeQuestions;
|
|
2013
|
+
var folderVersion = (folder) => folder.kind === "absent" ? null : folder.version;
|
|
2014
|
+
var wiringProblems = (facts) => [
|
|
2015
|
+
...facts.folder.kind === "absent" ? ["plugin_missing"] : [],
|
|
2016
|
+
...facts.settings.pluginEnabled ? [] : ["plugin_not_enabled"],
|
|
2017
|
+
...facts.settings.pluginDisabled ? ["plugin_disabled"] : []
|
|
2018
|
+
];
|
|
2019
|
+
var isUnwired = (facts) => facts.folder.kind === "absent" && !facts.settings.pluginEnabled && !routesHermesApprovals(facts.settings);
|
|
2020
|
+
var hermesWiringState = (facts) => {
|
|
2021
|
+
if (isUnwired(facts)) return { kind: "not_wired" };
|
|
2022
|
+
const problems = wiringProblems(facts);
|
|
2023
|
+
if (problems.length > 0) return { kind: "needs_repair", problems };
|
|
2024
|
+
const installedVersion = folderVersion(facts.folder);
|
|
2025
|
+
const recordsItsLoad = facts.folder.kind === "pushary";
|
|
2026
|
+
if (facts.gateway.kind === "not_running" || !recordsItsLoad) {
|
|
2027
|
+
return { kind: "connected", live: false, gatewayRunning: facts.gateway.kind === "running", version: installedVersion, runningVersion: null };
|
|
2028
|
+
}
|
|
2029
|
+
if (!facts.gatewayLoad) return { kind: "restart_needed", installedVersion };
|
|
2030
|
+
return { kind: "connected", live: true, gatewayRunning: true, version: installedVersion, runningVersion: facts.gatewayLoad.pluginVersion };
|
|
2031
|
+
};
|
|
2032
|
+
var runsAnOlderPlugin = (state) => state.kind === "connected" && state.live && state.runningVersion !== state.version;
|
|
2033
|
+
var isHermesWired = (state) => state.kind === "connected" || state.kind === "restart_needed";
|
|
2034
|
+
var hasUpdateFrom = (folder, installer, bundled) => folder.kind === "pushary" && folder.marker.installedBy === installer && bundledPluginReplaces(folder, bundled);
|
|
2035
|
+
var toolsetsWithoutPusharyQuestionRouting = (disabledToolsets, questionRoutingAdded) => {
|
|
2036
|
+
if (!questionRoutingAdded || !disabledToolsets.includes(HERMES_QUESTION_TOOLSET)) return null;
|
|
2037
|
+
return disabledToolsets.filter((toolset) => toolset !== HERMES_QUESTION_TOOLSET);
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// src/hermes/install.ts
|
|
2041
|
+
import { existsSync as existsSync2, rmSync as rmSync2 } from "fs";
|
|
2042
|
+
|
|
2043
|
+
// src/hermes/setup-receipt.ts
|
|
2044
|
+
var HERMES_SETUP_RECEIPT_VERSION = 2;
|
|
2045
|
+
var settingSnapshot = (key, reading) => reading.kind === "set" ? { key, wasSet: true, before: reading.value } : { key, wasSet: false, before: null };
|
|
2046
|
+
var isSettingSnapshot = (value) => isRecord(value) && typeof value.key === "string" && typeof value.wasSet === "boolean" && Object.hasOwn(value, "before");
|
|
2047
|
+
var parseCurrentReceipt = (value) => {
|
|
2048
|
+
const { settings, pluginWasEnabled, questionRoutingAdded } = value;
|
|
2049
|
+
if (!Array.isArray(settings) || !settings.every(isSettingSnapshot)) return null;
|
|
2050
|
+
if (typeof pluginWasEnabled !== "boolean" || typeof questionRoutingAdded !== "boolean") return null;
|
|
2051
|
+
return { settings, pluginWasEnabled, questionRoutingAdded };
|
|
2052
|
+
};
|
|
2053
|
+
var LEGACY_SNAPSHOT_KEYS = ["security", "approval", "transport", "fallback", "plugins", "enabled", "agent", "disabled"];
|
|
2054
|
+
var isLegacySnapshot = (value) => isRecord(value) && typeof value.present === "boolean" && Object.hasOwn(value, "value");
|
|
2055
|
+
var legacySetting = (key, snapshot) => ({ key, wasSet: snapshot.present, before: snapshot.present ? snapshot.value : null });
|
|
2056
|
+
var parseLegacyReceipt = (value) => {
|
|
2057
|
+
if (!LEGACY_SNAPSHOT_KEYS.every((key) => isLegacySnapshot(value[key]))) return null;
|
|
2058
|
+
if (typeof value.pushary_enabled !== "boolean" || typeof value.clarify_disabled !== "boolean") return null;
|
|
2059
|
+
const transport = value.transport;
|
|
2060
|
+
const fallback = value.fallback;
|
|
2061
|
+
if (!isLegacySnapshot(transport) || !isLegacySnapshot(fallback)) return null;
|
|
2062
|
+
return {
|
|
2063
|
+
settings: [legacySetting(HERMES_APPROVAL_TRANSPORT_KEY, transport), legacySetting(HERMES_APPROVAL_FALLBACK_KEY, fallback)],
|
|
2064
|
+
pluginWasEnabled: value.pushary_enabled,
|
|
2065
|
+
questionRoutingAdded: !value.clarify_disabled
|
|
2066
|
+
};
|
|
2067
|
+
};
|
|
2068
|
+
var parseHermesSetupReceipt = (value) => {
|
|
2069
|
+
if (!isRecord(value)) return null;
|
|
2070
|
+
return value.version === HERMES_SETUP_RECEIPT_VERSION ? parseCurrentReceipt(value) : parseLegacyReceipt(value);
|
|
2071
|
+
};
|
|
2072
|
+
var serializeHermesSetupReceipt = (receipt) => ({ version: HERMES_SETUP_RECEIPT_VERSION, ...receipt });
|
|
2073
|
+
var receiptWithoutSnapshots = () => ({
|
|
2074
|
+
settings: [
|
|
2075
|
+
{ key: HERMES_APPROVAL_TRANSPORT_KEY, wasSet: false, before: null },
|
|
2076
|
+
{ key: HERMES_APPROVAL_FALLBACK_KEY, wasSet: false, before: null }
|
|
2077
|
+
],
|
|
2078
|
+
pluginWasEnabled: false,
|
|
2079
|
+
questionRoutingAdded: false
|
|
2080
|
+
});
|
|
2081
|
+
var receiptAfterQuestionRoutingReturned = (receipt) => ({ ...receipt, questionRoutingAdded: false });
|
|
2082
|
+
var PUSHARY_VALUES = {
|
|
2083
|
+
[HERMES_APPROVAL_TRANSPORT_KEY]: HERMES_PUSHARY_TRANSPORT,
|
|
2084
|
+
[HERMES_APPROVAL_FALLBACK_KEY]: HERMES_PUSHARY_FALLBACK
|
|
2085
|
+
};
|
|
2086
|
+
var hermesSettingRestore = (snapshot, current) => {
|
|
2087
|
+
const ours = PUSHARY_VALUES[snapshot.key];
|
|
2088
|
+
const stillOurs = current.kind === "set" && current.value === ours;
|
|
2089
|
+
const oursBeforeSetup = snapshot.wasSet && snapshot.before === ours;
|
|
2090
|
+
return stillOurs && !oursBeforeSetup ? { kind: "restore", key: snapshot.key, snapshot } : { kind: "leave", key: snapshot.key };
|
|
2091
|
+
};
|
|
2092
|
+
var sameSettingValue = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
2093
|
+
|
|
2094
|
+
// src/hermes/install.ts
|
|
2095
|
+
var RECEIPT_MODE = 384;
|
|
2096
|
+
var inspectWithLauncher = (launcher) => {
|
|
2097
|
+
const home = readHermesHome(launcher);
|
|
2098
|
+
const settings = home ? readHermesSettings(launcher) : null;
|
|
2099
|
+
if (!home || !settings) return { kind: "unreadable", home };
|
|
2100
|
+
const folder = classifyHermesPluginFolder(readHermesPluginFolderFacts(home));
|
|
2101
|
+
const gateway = readHermesGateway(home);
|
|
2102
|
+
const gatewayLoad = gateway.kind === "running" ? readHermesLoadRecord(home, gateway.pid) : null;
|
|
2103
|
+
const state = hermesWiringState({ folder, settings, gateway, gatewayLoad });
|
|
2104
|
+
return { kind: "inspected", launcher, home, folder, settings, gateway, state };
|
|
2105
|
+
};
|
|
2106
|
+
var findLauncherWhereHermesLives = () => existsSync2(defaultHermesHome()) ? findHermesLauncher() : null;
|
|
2107
|
+
var inspectHermesWiring = (launcher) => {
|
|
2108
|
+
const resolved = launcher === void 0 ? findLauncherWhereHermesLives() : launcher;
|
|
2109
|
+
return resolved ? inspectWithLauncher(resolved) : { kind: "no_launcher" };
|
|
2110
|
+
};
|
|
2111
|
+
var hermesReceiptPaths = () => ({
|
|
2112
|
+
receiptPath: hermesSetupReceipt(),
|
|
2113
|
+
legacyReceiptPath: hermesConfigReceipt()
|
|
2114
|
+
});
|
|
2115
|
+
var hermesSetupWasRecorded = (receipts = hermesReceiptPaths()) => existsSync2(receipts.receiptPath) || existsSync2(receipts.legacyReceiptPath);
|
|
2116
|
+
var pipSetupFacts = () => ({
|
|
2117
|
+
receipts: hermesReceiptPaths(),
|
|
2118
|
+
macAppWiringRecord: hermesAppWiringRecord(),
|
|
2119
|
+
home: defaultHermesHome()
|
|
2120
|
+
});
|
|
2121
|
+
var hermesSetupPredatesThePluginFolder = (facts = pipSetupFacts()) => existsSync2(facts.receipts.legacyReceiptPath) && !existsSync2(facts.receipts.receiptPath) && !existsSync2(facts.macAppWiringRecord) && !existsSync2(hermesPluginDirectory(facts.home));
|
|
2122
|
+
var readReceiptFile = (path) => {
|
|
2123
|
+
const read = readJsonSafe(path);
|
|
2124
|
+
if (read.kind === "missing") return { kind: "missing" };
|
|
2125
|
+
const receipt = read.kind === "ok" ? parseHermesSetupReceipt(read.value) : null;
|
|
2126
|
+
return receipt ? { kind: "ok", receipt } : { kind: "invalid" };
|
|
2127
|
+
};
|
|
2128
|
+
var readSetupReceipt = (receipts) => {
|
|
2129
|
+
const current = readReceiptFile(receipts.receiptPath);
|
|
2130
|
+
return current.kind === "missing" ? readReceiptFile(receipts.legacyReceiptPath) : current;
|
|
2131
|
+
};
|
|
2132
|
+
var writeSetupReceipt = (receipts, receipt) => writeJsonAtomic(receipts.receiptPath, serializeHermesSetupReceipt(receipt), RECEIPT_MODE);
|
|
2133
|
+
var removeSetupReceipts = (receipts) => {
|
|
2134
|
+
rmSync2(receipts.receiptPath, { force: true });
|
|
2135
|
+
rmSync2(receipts.legacyReceiptPath, { force: true });
|
|
2136
|
+
};
|
|
2137
|
+
var snapshotSettings = (launcher) => {
|
|
2138
|
+
const settings = readHermesSettings(launcher);
|
|
2139
|
+
if (!settings) return null;
|
|
2140
|
+
const snapshots = [];
|
|
2141
|
+
for (const key of HERMES_SNAPSHOT_KEYS) {
|
|
2142
|
+
const reading = readHermesSetting(launcher, key);
|
|
2143
|
+
if (!reading) return null;
|
|
2144
|
+
snapshots.push(settingSnapshot(key, reading));
|
|
2145
|
+
}
|
|
2146
|
+
return {
|
|
2147
|
+
settings: snapshots,
|
|
2148
|
+
pluginWasEnabled: settings.pluginEnabled,
|
|
2149
|
+
questionRoutingAdded: false
|
|
2150
|
+
};
|
|
2151
|
+
};
|
|
2152
|
+
var ensureSetupReceipt = (launcher, receipts) => {
|
|
2153
|
+
const existing = readSetupReceipt(receipts);
|
|
2154
|
+
if (existing.kind === "invalid") return { kind: "failed", reason: "receipt_invalid" };
|
|
2155
|
+
const receipt = existing.kind === "ok" ? existing.receipt : snapshotSettings(launcher);
|
|
2156
|
+
if (!receipt) return { kind: "failed", reason: "settings_unreadable" };
|
|
2157
|
+
if (!existsSync2(receipts.receiptPath)) writeSetupReceipt(receipts, receipt);
|
|
2158
|
+
return { kind: "ok", receipt };
|
|
2159
|
+
};
|
|
2160
|
+
var settingNeedsWrite = (launcher, key, value) => {
|
|
2161
|
+
const current = readHermesSetting(launcher, key);
|
|
2162
|
+
return !(current?.kind === "set" && current.value === value);
|
|
2163
|
+
};
|
|
2164
|
+
var applyPusharySettings = (launcher) => {
|
|
2165
|
+
if (!enableHermesPlugin(launcher)) return false;
|
|
2166
|
+
return HERMES_PUSHARY_SETTINGS.every((setting) => !settingNeedsWrite(launcher, setting.key, setting.value) || writeHermesSetting(launcher, setting.key, setting.value));
|
|
2167
|
+
};
|
|
2168
|
+
var writeListSetting = (launcher, key, list) => {
|
|
2169
|
+
if (list.length > 0) return writeHermesSetting(launcher, key, list);
|
|
2170
|
+
if (!unsetHermesSetting(launcher, key)) return false;
|
|
2171
|
+
const after = readHermesSetting(launcher, key);
|
|
2172
|
+
return after?.kind !== "set" || sameSettingValue(after.value, []) || writeHermesSetting(launcher, key, []);
|
|
2173
|
+
};
|
|
2174
|
+
var returnQuestionsToHermes = (launcher, receipt) => {
|
|
2175
|
+
const toolsets = readHermesSetting(launcher, HERMES_DISABLED_TOOLSETS_KEY);
|
|
2176
|
+
if (!toolsets) return false;
|
|
2177
|
+
const current = toolsets.kind === "set" && Array.isArray(toolsets.value) ? toolsets.value.filter((item) => typeof item === "string") : [];
|
|
2178
|
+
const next = toolsetsWithoutPusharyQuestionRouting(current, receipt.questionRoutingAdded);
|
|
2179
|
+
return next === null || writeListSetting(launcher, HERMES_DISABLED_TOOLSETS_KEY, next);
|
|
2180
|
+
};
|
|
2181
|
+
var removeLegacyCopy = (launcher) => {
|
|
2182
|
+
const python = legacyHermesPython(launcher);
|
|
2183
|
+
return python !== null && removeLegacyPipCopy(python, HERMES_LEGACY_PIP_PACKAGE);
|
|
2184
|
+
};
|
|
2185
|
+
var returnQuestionsIfThePluginRoutesThem = (launcher, home, receipts, receipt) => {
|
|
2186
|
+
const folder = classifyHermesPluginFolder(readHermesPluginFolderFacts(home));
|
|
2187
|
+
if (!receipt.questionRoutingAdded || !routesHermesQuestions(folder)) return true;
|
|
2188
|
+
if (!returnQuestionsToHermes(launcher, receipt)) return false;
|
|
2189
|
+
writeSetupReceipt(receipts, receiptAfterQuestionRoutingReturned(receipt));
|
|
2190
|
+
return true;
|
|
2191
|
+
};
|
|
2192
|
+
var installHermesPlugin = (request) => {
|
|
2193
|
+
const launcher = request.launcher === void 0 ? findHermesLauncher() : request.launcher;
|
|
2194
|
+
if (!launcher) return { kind: "no_launcher" };
|
|
2195
|
+
const home = readHermesHome(launcher);
|
|
2196
|
+
if (!home) return { kind: "failed", reason: "home_unknown" };
|
|
2197
|
+
const ensured = ensureSetupReceipt(launcher, request.receipts);
|
|
2198
|
+
if (ensured.kind === "failed") return ensured;
|
|
2199
|
+
const folder = classifyHermesPluginFolder(readHermesPluginFolderFacts(home));
|
|
2200
|
+
if (folder.kind === "unknown") return { kind: "failed", reason: "plugin_folder_not_ours" };
|
|
2201
|
+
const folderWritten = bundledPluginReplaces(folder, request.plugin);
|
|
2202
|
+
if (folderWritten) {
|
|
2203
|
+
try {
|
|
2204
|
+
writeHermesPluginFolder(home, request.plugin.files, hermesInstallMarker(request.installedBy, request.plugin));
|
|
2205
|
+
} catch {
|
|
2206
|
+
return { kind: "failed", reason: "plugin_write_failed" };
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (!applyPusharySettings(launcher)) return { kind: "failed", reason: "settings_failed" };
|
|
2210
|
+
if (!returnQuestionsIfThePluginRoutesThem(launcher, home, request.receipts, ensured.receipt)) {
|
|
2211
|
+
return { kind: "failed", reason: "settings_failed" };
|
|
2212
|
+
}
|
|
2213
|
+
const legacyCopyRemoved = removeLegacyCopy(launcher);
|
|
2214
|
+
return { kind: "installed", folderWritten, legacyCopyRemoved, inspection: inspectWithLauncher(launcher) };
|
|
2215
|
+
};
|
|
2216
|
+
var refreshHermesPluginFiles = (installedBy, plugin, home = defaultHermesHome()) => {
|
|
2217
|
+
const folder = classifyHermesPluginFolder(readHermesPluginFolderFacts(home));
|
|
2218
|
+
if (folder.kind !== "pushary" || folder.marker.installedBy !== installedBy) return "not_ours";
|
|
2219
|
+
if (!hasUpdateFrom(folder, installedBy, plugin)) return "current";
|
|
2220
|
+
try {
|
|
2221
|
+
writeHermesPluginFolder(home, plugin.files, hermesInstallMarker(installedBy, plugin));
|
|
2222
|
+
return "refreshed";
|
|
2223
|
+
} catch {
|
|
2224
|
+
return "failed";
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
var macAppOwnsHermes = (folder, request) => hermesWiringOwner(folder, existsSync2(request.macAppWiringRecord)) === "mac_app";
|
|
2228
|
+
var removePluginEntry = (launcher, settings) => {
|
|
2229
|
+
if (!settings.pluginEnabled || disableHermesPlugin(launcher)) return true;
|
|
2230
|
+
const enabled = readHermesSetting(launcher, HERMES_PLUGINS_ENABLED_KEY);
|
|
2231
|
+
const list = enabled?.kind === "set" && Array.isArray(enabled.value) ? enabled.value : [];
|
|
2232
|
+
return writeListSetting(
|
|
2233
|
+
launcher,
|
|
2234
|
+
HERMES_PLUGINS_ENABLED_KEY,
|
|
2235
|
+
list.filter((item) => typeof item === "string" && item !== HERMES_PLUGIN_KEY)
|
|
2236
|
+
);
|
|
2237
|
+
};
|
|
2238
|
+
var removePushPlugin = (launcher, home, folder, settings, receipt) => {
|
|
2239
|
+
removeLegacyCopy(launcher);
|
|
2240
|
+
if (folder.kind === "pushary") {
|
|
2241
|
+
if (removeHermesPlugin(launcher)) return true;
|
|
2242
|
+
rmSync2(hermesPluginDirectory(home), { recursive: true, force: true });
|
|
2243
|
+
return removePluginEntry(launcher, settings);
|
|
2244
|
+
}
|
|
2245
|
+
return receipt.pluginWasEnabled || removePluginEntry(launcher, settings);
|
|
2246
|
+
};
|
|
2247
|
+
var restoreSetting = (launcher, snapshot) => {
|
|
2248
|
+
const current = readHermesSetting(launcher, snapshot.key);
|
|
2249
|
+
if (!current) return false;
|
|
2250
|
+
if (hermesSettingRestore(snapshot, current).kind === "leave") return true;
|
|
2251
|
+
if (!unsetHermesSetting(launcher, snapshot.key)) return false;
|
|
2252
|
+
if (!snapshot.wasSet) return true;
|
|
2253
|
+
const after = readHermesSetting(launcher, snapshot.key);
|
|
2254
|
+
return after?.kind === "set" && sameSettingValue(after.value, snapshot.before) ? true : writeHermesSetting(launcher, snapshot.key, snapshot.before);
|
|
2255
|
+
};
|
|
2256
|
+
var macAppOwnsTheFolderOnDisk = (request) => macAppOwnsHermes(classifyHermesPluginFolder(readHermesPluginFolderFacts(request.home ?? defaultHermesHome())), request);
|
|
2257
|
+
var removeHermesWiring = (request) => {
|
|
2258
|
+
if (request.keepMacAppWiring && macAppOwnsTheFolderOnDisk(request)) return { kind: "kept_for_mac_app" };
|
|
2259
|
+
const receiptRead = readSetupReceipt(request.receipts);
|
|
2260
|
+
const launcher = request.launcher === void 0 ? receiptRead.kind === "missing" ? findLauncherWhereHermesLives() : findHermesLauncher() : request.launcher;
|
|
2261
|
+
if (!launcher) return receiptRead.kind === "missing" ? { kind: "not_found" } : { kind: "runtime_missing" };
|
|
2262
|
+
if (receiptRead.kind === "invalid") return { kind: "failed", step: "receipt_invalid" };
|
|
2263
|
+
const inspection = inspectWithLauncher(launcher);
|
|
2264
|
+
if (inspection.kind !== "inspected") {
|
|
2265
|
+
return receiptRead.kind === "missing" ? { kind: "not_found" } : { kind: "failed", step: "home_unknown" };
|
|
2266
|
+
}
|
|
2267
|
+
const { home, folder, settings, state } = inspection;
|
|
2268
|
+
if (request.keepMacAppWiring && macAppOwnsHermes(folder, request)) return { kind: "kept_for_mac_app" };
|
|
2269
|
+
if (state.kind === "not_wired" && receiptRead.kind === "missing") return { kind: "not_found" };
|
|
2270
|
+
const receipt = receiptRead.kind === "ok" ? receiptRead.receipt : receiptWithoutSnapshots();
|
|
2271
|
+
if (request.dryRun) return { kind: "removed", pluginRemoved: folder.kind === "pushary", settingsRestored: true, dryRun: true };
|
|
2272
|
+
if (!removePushPlugin(launcher, home, folder, settings, receipt)) return { kind: "failed", step: "plugin" };
|
|
2273
|
+
const settingsRestored = receipt.settings.every((snapshot) => restoreSetting(launcher, snapshot)) && returnQuestionsToHermes(launcher, receipt);
|
|
2274
|
+
if (!settingsRestored) return { kind: "failed", step: "settings" };
|
|
2275
|
+
removeSetupReceipts(request.receipts);
|
|
2276
|
+
return { kind: "removed", pluginRemoved: folder.kind === "pushary", settingsRestored, dryRun: false };
|
|
2277
|
+
};
|
|
2278
|
+
|
|
2279
|
+
export {
|
|
2280
|
+
shippedHermesPlugin,
|
|
2281
|
+
findHermesLauncher,
|
|
2282
|
+
hermesPluginDirectory,
|
|
2283
|
+
HERMES_CONFIG_FILE,
|
|
2284
|
+
defaultHermesHome,
|
|
2285
|
+
hermesWiringOwner,
|
|
2286
|
+
routesHermesApprovals,
|
|
2287
|
+
routesHermesQuestions,
|
|
2288
|
+
runsAnOlderPlugin,
|
|
2289
|
+
isHermesWired,
|
|
2290
|
+
hasUpdateFrom,
|
|
2291
|
+
inspectHermesWiring,
|
|
2292
|
+
hermesReceiptPaths,
|
|
2293
|
+
hermesSetupWasRecorded,
|
|
2294
|
+
hermesSetupPredatesThePluginFolder,
|
|
2295
|
+
installHermesPlugin,
|
|
2296
|
+
refreshHermesPluginFiles,
|
|
2297
|
+
removeHermesWiring
|
|
2298
|
+
};
|