pushary 1.9.66 → 1.9.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/data/SKILL.md +1 -1
  3. package/data/cursor-plugin/skills/pushary/SKILL.md +1 -1
  4. package/data/vscode-plugin/skills/pushary/SKILL.md +1 -1
  5. package/dist/{api-GDSVUTI2.js → api-6Q7XIB67.js} +10 -10
  6. package/dist/bin/pushary-bell-hook.js +25 -25
  7. package/dist/bin/pushary-bell.js +10 -10
  8. package/dist/bin/pushary-claude.js +26 -26
  9. package/dist/bin/pushary-clean.js +73 -74
  10. package/dist/bin/pushary-codex-bridge.js +21 -21
  11. package/dist/bin/pushary-codex-hook.js +30 -30
  12. package/dist/bin/pushary-codex-session.js +8 -8
  13. package/dist/bin/pushary-codex.js +13 -13
  14. package/dist/bin/pushary-connect.js +28 -27
  15. package/dist/bin/pushary-cowork.js +5 -5
  16. package/dist/bin/pushary-daemon.js +40 -39
  17. package/dist/bin/pushary-disconnect.js +25 -25
  18. package/dist/bin/pushary-doctor.js +126 -72
  19. package/dist/bin/pushary-elicitation-hook.js +25 -25
  20. package/dist/bin/pushary-gemini-bridge.js +15 -15
  21. package/dist/bin/pushary-gemini-hook.js +27 -27
  22. package/dist/bin/pushary-hook.js +29 -29
  23. package/dist/bin/pushary-login.js +20 -19
  24. package/dist/bin/pushary-logout.js +26 -27
  25. package/dist/bin/pushary-mcp.js +11 -11
  26. package/dist/bin/pushary-mode.js +16 -15
  27. package/dist/bin/pushary-notification-hook.js +24 -24
  28. package/dist/bin/pushary-opencode-hook.js +26 -26
  29. package/dist/bin/pushary-permission-denied-hook.js +29 -29
  30. package/dist/bin/pushary-permission-hook.js +29 -29
  31. package/dist/bin/pushary-post-hook.js +24 -24
  32. package/dist/bin/pushary-prompt-hook.js +24 -24
  33. package/dist/bin/pushary-session-end-hook.js +26 -26
  34. package/dist/bin/pushary-session-start-hook.js +34 -33
  35. package/dist/bin/pushary-setup.js +115 -98
  36. package/dist/bin/pushary-stats.js +5 -5
  37. package/dist/bin/pushary-status.js +37 -35
  38. package/dist/bin/pushary-stop-hook.js +24 -24
  39. package/dist/bin/pushary-stopfailure-hook.js +24 -24
  40. package/dist/bin/pushary-suggestions.js +15 -14
  41. package/dist/bin/pushary-transcript-register.js +16 -16
  42. package/dist/bin/pushary-transcripts.js +18 -17
  43. package/dist/bin/pushary-upgrade.js +66 -38
  44. package/dist/bin/pushary-wait.js +26 -20
  45. package/dist/bin/pushary.js +7 -7
  46. package/dist/{chunk-ZJV7SFIJ.js → chunk-2HMKZYF5.js} +1 -1
  47. package/dist/{chunk-IA5VWIHV.js → chunk-2VYQG2HG.js} +2 -2
  48. package/dist/{chunk-A47UKPC7.js → chunk-32OSSYGZ.js} +1 -1
  49. package/dist/{chunk-DBUHSUL7.js → chunk-4TIN3T2A.js} +1 -1
  50. package/dist/{chunk-JL3SZJJP.js → chunk-53Q4LRBU.js} +2 -2
  51. package/dist/{chunk-HUGVJBDN.js → chunk-53RH3ZIK.js} +2 -2
  52. package/dist/{chunk-2BL6KWN7.js → chunk-6IFEYELN.js} +9 -9
  53. package/dist/{chunk-DKMVVBXO.js → chunk-6VFZVL65.js} +55 -13
  54. package/dist/{chunk-YQDCTID4.js → chunk-73DHP3JX.js} +4 -4
  55. package/dist/{chunk-G6VJZ3RB.js → chunk-77L2TNJW.js} +1 -1
  56. package/dist/{chunk-IQ3GKPFM.js → chunk-77QP22FL.js} +1 -1
  57. package/dist/{chunk-VURDLVAQ.js → chunk-7S7NTMDJ.js} +3 -3
  58. package/dist/{chunk-4APD2DPI.js → chunk-7SKGS7QN.js} +53 -3
  59. package/dist/chunk-7V63A4AI.js +25 -0
  60. package/dist/{chunk-6BGVSV2Q.js → chunk-7YJSDXLM.js} +1 -1
  61. package/dist/{chunk-MNNQEUQC.js → chunk-AJIT4SCL.js} +27 -8
  62. package/dist/{chunk-QR2QWGQI.js → chunk-BSMHSN3Y.js} +3 -3
  63. package/dist/{chunk-R7C2FS33.js → chunk-C4EDAHXI.js} +1 -1
  64. package/dist/{chunk-7LK3Y6ET.js → chunk-CB4U6WRP.js} +6 -6
  65. package/dist/chunk-D6W7QDQE.js +2298 -0
  66. package/dist/{chunk-V4EOFOB2.js → chunk-E5PEOVUJ.js} +6 -6
  67. package/dist/{chunk-32ZT2ATY.js → chunk-EOWHEZHD.js} +8 -8
  68. package/dist/{chunk-57NTMVMI.js → chunk-EUU5PDUI.js} +6 -6
  69. package/dist/{chunk-XXIJBYNW.js → chunk-GMHQYX3D.js} +1 -1
  70. package/dist/{chunk-V3YBVOTZ.js → chunk-I5FFSOCV.js} +2 -2
  71. package/dist/{chunk-RJ6JEHCP.js → chunk-JAKWPQ2L.js} +1 -1
  72. package/dist/{chunk-XDDSURY3.js → chunk-JPVV27O2.js} +3 -3
  73. package/dist/{chunk-GWYCKTXS.js → chunk-K4TSXWVX.js} +5 -5
  74. package/dist/chunk-LF2NSOAQ.js +299 -0
  75. package/dist/{chunk-6RJZSBPD.js → chunk-LGCVAE52.js} +1 -1
  76. package/dist/{chunk-GDLT32I4.js → chunk-LQAAOXYJ.js} +21 -13
  77. package/dist/{chunk-3ELJIG2R.js → chunk-MCNHNIRC.js} +3 -3
  78. package/dist/{chunk-XIJTXRWB.js → chunk-METUT7V4.js} +2 -2
  79. package/dist/{chunk-MDIGG3Q7.js → chunk-MFWHANKG.js} +3 -3
  80. package/dist/{chunk-GECU7V44.js → chunk-MHC34X5A.js} +3 -3
  81. package/dist/{chunk-376TUN72.js → chunk-MMTA7UKA.js} +3 -3
  82. package/dist/{chunk-XGFFNERF.js → chunk-MUDUWF74.js} +1 -1
  83. package/dist/{chunk-KS3O6O7E.js → chunk-NAQCWM4U.js} +3 -3
  84. package/dist/{chunk-TQNFDNJA.js → chunk-NKPSJOHJ.js} +6 -6
  85. package/dist/{chunk-DPU6H4JY.js → chunk-NLDEC4Y7.js} +3 -3
  86. package/dist/{chunk-XEXF2OFZ.js → chunk-NWN6GGJL.js} +2 -2
  87. package/dist/{chunk-IGO3AGZK.js → chunk-O655YCOG.js} +4 -4
  88. package/dist/{chunk-5FJDODOA.js → chunk-ODAAK2RZ.js} +3 -3
  89. package/dist/{chunk-AVWECJSN.js → chunk-ORHVVRVL.js} +2 -2
  90. package/dist/{chunk-YB3HMDKE.js → chunk-P4XZX7CL.js} +2 -2
  91. package/dist/{chunk-CNJ7SI5V.js → chunk-PBEW5CJK.js} +4 -4
  92. package/dist/{chunk-CN6JFM62.js → chunk-PEPRUUGM.js} +15 -6
  93. package/dist/{chunk-QN25WBXL.js → chunk-PGOPIIOQ.js} +7 -7
  94. package/dist/{chunk-Q5RNYE2L.js → chunk-PZZKXX6Q.js} +4 -4
  95. package/dist/{chunk-SVKCYUYP.js → chunk-Q6JXE3PD.js} +9 -9
  96. package/dist/{chunk-ZAZUDYSF.js → chunk-QYTOYNV2.js} +5 -5
  97. package/dist/{chunk-3FQX74E4.js → chunk-REQO3WWO.js} +23 -8
  98. package/dist/{chunk-EJR5JXMV.js → chunk-RQOT6QL3.js} +43 -5
  99. package/dist/{chunk-AWGBL4P3.js → chunk-RWXDWOQQ.js} +89 -41
  100. package/dist/{chunk-6ZXCWK27.js → chunk-SL7CIZCL.js} +60 -6
  101. package/dist/{chunk-3EZP5ZPS.js → chunk-TVVJ6OYG.js} +4 -4
  102. package/dist/{chunk-B6JC676X.js → chunk-UC2J5RR4.js} +1 -1
  103. package/dist/{chunk-26W46E46.js → chunk-UETS2WEA.js} +27 -10
  104. package/dist/{chunk-EU2ZOZC3.js → chunk-UXN73YAE.js} +3 -3
  105. package/dist/{chunk-XFQAK35L.js → chunk-VHMZPN3N.js} +1 -1
  106. package/dist/{chunk-45KMS76L.js → chunk-VKZ3RJ5U.js} +1 -1
  107. package/dist/{chunk-T6YLXO6N.js → chunk-VRFFMTIG.js} +4 -4
  108. package/dist/{chunk-DIUTSF6I.js → chunk-WPOXZPEH.js} +4 -4
  109. package/dist/{chunk-GO7YKPT3.js → chunk-X2MTVD4W.js} +1 -1
  110. package/dist/{chunk-5J7OZBJ4.js → chunk-XQKB4LEK.js} +2 -2
  111. package/dist/{chunk-QOIPUYUD.js → chunk-XZKSUJ55.js} +1 -1
  112. package/dist/{chunk-RSZQEEEW.js → chunk-YZHJQG6W.js} +8 -6
  113. package/dist/{chunk-UTAARXYQ.js → chunk-ZK5WYAMI.js} +1 -1
  114. package/dist/{codex-app-cards-switch-W7OLKOMO.js → codex-app-cards-switch-VZ4XIDJO.js} +11 -11
  115. package/dist/{codex-desktop-launch-U6V3AXZF.js → codex-desktop-launch-CX2IE5BI.js} +1 -1
  116. package/dist/{codex-session-launch-EFU46WBL.js → codex-session-launch-WUIM6ZGA.js} +18 -18
  117. package/dist/{config-UC4LMCNX.js → config-OBGYURFX.js} +3 -3
  118. package/dist/{global-upgrade-ABF5O3HU.js → global-upgrade-XZ5L7LDB.js} +10 -10
  119. package/dist/{identity-3ZUZPHN6.js → identity-TGQPFIE4.js} +4 -4
  120. package/dist/{live-capabilities-BXWXJARK.js → live-capabilities-JHESROAP.js} +5 -5
  121. package/dist/{paths-FO76CFRQ.js → paths-EOMNC4QM.js} +7 -3
  122. package/dist/{reapply-3T3DFUAI.js → reapply-J5HHUUBE.js} +27 -26
  123. package/dist/src/index.js +28 -28
  124. package/dist/{upgrade-lock-EZ5JIPJE.js → upgrade-lock-VQORI6G5.js} +4 -4
  125. package/dist/{usage-Y6BL7RUD.js → usage-KX2HN47E.js} +3 -3
  126. package/package.json +1 -1
  127. package/dist/chunk-QBNICXMC.js +0 -615
@@ -0,0 +1,2298 @@
1
+ import {
2
+ needsShell
3
+ } from "./chunk-XHKBHWLX.js";
4
+ import {
5
+ hermesAppWiringRecord,
6
+ hermesConfigReceipt,
7
+ hermesSetupReceipt
8
+ } from "./chunk-LQAAOXYJ.js";
9
+ import {
10
+ readJsonSafe,
11
+ resolvePathBinary,
12
+ writeJsonAtomic
13
+ } from "./chunk-NAQCWM4U.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
+ };