pushary 1.9.100 → 1.9.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/{api-H734WJ2V.js → api-3O3GJV3V.js} +7 -7
- package/dist/bin/pushary-bell-hook.js +18 -18
- package/dist/bin/pushary-bell.js +5 -5
- package/dist/bin/pushary-claude.js +19 -19
- package/dist/bin/pushary-clean.js +26 -26
- package/dist/bin/pushary-codex-bridge.js +14 -14
- package/dist/bin/pushary-codex-hook.js +21 -21
- package/dist/bin/pushary-codex-session.js +6 -6
- package/dist/bin/pushary-codex.js +8 -8
- package/dist/bin/pushary-connect.js +19 -19
- package/dist/bin/pushary-daemon.js +30 -30
- package/dist/bin/pushary-disconnect.js +18 -18
- package/dist/bin/pushary-display-hook.js +10 -10
- package/dist/bin/pushary-doctor.js +32 -32
- package/dist/bin/pushary-elicitation-hook.js +18 -18
- package/dist/bin/pushary-gemini-bridge.js +9 -9
- package/dist/bin/pushary-gemini-hook.js +19 -19
- package/dist/bin/pushary-hook.js +20 -20
- package/dist/bin/pushary-login.js +14 -14
- package/dist/bin/pushary-logout.js +16 -16
- package/dist/bin/pushary-mcp.js +8 -8
- package/dist/bin/pushary-mode.js +9 -9
- package/dist/bin/pushary-notification-hook.js +17 -17
- package/dist/bin/pushary-opencode-hook.js +18 -18
- package/dist/bin/pushary-permission-denied-hook.js +20 -20
- package/dist/bin/pushary-permission-hook.js +20 -20
- package/dist/bin/pushary-post-hook.js +17 -17
- package/dist/bin/pushary-prompt-hook.js +17 -17
- package/dist/bin/pushary-session-end-hook.js +19 -19
- package/dist/bin/pushary-session-start-hook.js +27 -27
- package/dist/bin/pushary-setup.js +39 -39
- package/dist/bin/pushary-status.js +23 -23
- package/dist/bin/pushary-stop-hook.js +17 -17
- package/dist/bin/pushary-stopfailure-hook.js +17 -17
- package/dist/bin/pushary-suggestions.js +9 -9
- package/dist/bin/pushary-transcript-register.js +11 -11
- package/dist/bin/pushary-transcripts.js +18 -18
- package/dist/bin/pushary-upgrade.js +32 -32
- package/dist/bin/pushary-wait.js +9 -9
- package/dist/bin/pushary.js +4 -4
- package/dist/{chunk-5QB6P3I7.js → chunk-2PR5EP7N.js} +1 -1
- package/dist/{chunk-N6UPDKCI.js → chunk-2S5J5YI2.js} +3 -3
- package/dist/{chunk-RAASN7OG.js → chunk-3ITURBPE.js} +1 -1
- package/dist/{chunk-SOBHT3J7.js → chunk-3K7PB5LI.js} +2 -2
- package/dist/{chunk-ZNPUYLOY.js → chunk-3Q2X3T3U.js} +1 -1
- package/dist/{chunk-DCYA2SW2.js → chunk-6N5DQG5F.js} +2 -2
- package/dist/{chunk-O35JKL4T.js → chunk-7BIVBOD5.js} +1 -1
- package/dist/{chunk-BIUD3TAY.js → chunk-7OZOFTTB.js} +4 -4
- package/dist/{chunk-DPGG3AAG.js → chunk-AVXG5QB2.js} +4 -4
- package/dist/{chunk-CC24CQ7P.js → chunk-AWF5WICI.js} +2 -2
- package/dist/{chunk-ERO2ERIE.js → chunk-AZSOH2SF.js} +1 -1
- package/dist/{chunk-GKNTNKVQ.js → chunk-BQHZRH6S.js} +9 -3
- package/dist/{chunk-6VFN4XTI.js → chunk-CQ7QE4W2.js} +1 -1
- package/dist/{chunk-33YOO7FG.js → chunk-E7WJQBK3.js} +8 -8
- package/dist/{chunk-A7JQJUGZ.js → chunk-EOD3DBIN.js} +2 -2
- package/dist/{chunk-43CU2HYQ.js → chunk-FF7L4B3W.js} +4 -4
- package/dist/{chunk-BBNMGXZA.js → chunk-G7RMOSRX.js} +1 -1
- package/dist/{chunk-THHVB46D.js → chunk-HYAQIKYB.js} +1 -1
- package/dist/{chunk-Z6HHEV7A.js → chunk-IOQ3ZCAE.js} +1 -1
- package/dist/{chunk-YVAFDGK2.js → chunk-J6SSLXSU.js} +3 -3
- package/dist/{chunk-IGIWULLQ.js → chunk-JECH4NJL.js} +1 -1
- package/dist/{chunk-W44TA4CT.js → chunk-JKOAAIJD.js} +4 -4
- package/dist/{chunk-XEGKTXTC.js → chunk-JPKJBKZE.js} +3 -3
- package/dist/{chunk-GSQZTDPZ.js → chunk-JUA7MX2V.js} +3 -3
- package/dist/{chunk-2SFVZ3IT.js → chunk-JVE4P54I.js} +11 -4
- package/dist/{chunk-ZG6M5VQV.js → chunk-KLJ2CTT7.js} +1 -1
- package/dist/{chunk-3BBJO25D.js → chunk-KTM2X3XQ.js} +7 -7
- package/dist/{chunk-UPQE4YPM.js → chunk-LURFSDCL.js} +1 -1
- package/dist/{chunk-SQSL6DVX.js → chunk-MA65OP6M.js} +9 -9
- package/dist/{chunk-SJXNDL73.js → chunk-MK2XE66G.js} +2 -2
- package/dist/{chunk-E4YZIS4C.js → chunk-NZQEABRO.js} +4 -4
- package/dist/{chunk-4TLE7CSQ.js → chunk-O5ZKMUTP.js} +3 -3
- package/dist/{chunk-SGECX2RY.js → chunk-O7ITTNDD.js} +1 -1
- package/dist/{chunk-XJF4WX5C.js → chunk-PM34Z5WX.js} +4 -4
- package/dist/{chunk-IGEY7ZFQ.js → chunk-PPWEBIKL.js} +627 -6
- package/dist/{chunk-DIWNHZ6I.js → chunk-RVSRPDRF.js} +2 -2
- package/dist/{chunk-E2YENQFD.js → chunk-SA4PCB6N.js} +6 -6
- package/dist/{chunk-GTUCRZRJ.js → chunk-SRE6XJ3K.js} +5 -5
- package/dist/{chunk-K6QZMKAP.js → chunk-SXAZPWL7.js} +5 -5
- package/dist/{chunk-SSLEGJYI.js → chunk-T6TQETDN.js} +1 -1
- package/dist/{chunk-E34NVKR7.js → chunk-T7G6IIV4.js} +2 -2
- package/dist/{chunk-NTTFATKG.js → chunk-UOE26LPT.js} +3 -3
- package/dist/{chunk-MY7NJM56.js → chunk-UQWEH7CK.js} +3 -3
- package/dist/{chunk-VMXY3PAT.js → chunk-V5DV7XZN.js} +2 -2
- package/dist/{chunk-OUF7TX2C.js → chunk-VD6CAELE.js} +2 -2
- package/dist/{chunk-T72OMEHB.js → chunk-VJOMBYY5.js} +1 -1
- package/dist/{chunk-ACVL2MHG.js → chunk-WYJ6QM7K.js} +1 -1
- package/dist/{chunk-WRCQCJA7.js → chunk-WZNLWDSL.js} +4 -4
- package/dist/{chunk-FIKNFZ7Z.js → chunk-X3HBR57D.js} +5 -5
- package/dist/{chunk-BFEY6FRZ.js → chunk-Y3SZVZF6.js} +2 -2
- package/dist/{chunk-CK6YJZEK.js → chunk-Y7KRX7U4.js} +1 -1
- package/dist/{chunk-4JUQ74CG.js → chunk-YETNUPV5.js} +2 -2
- package/dist/{chunk-KUIJMRES.js → chunk-Z2TX7DVU.js} +5 -5
- package/dist/{chunk-7ZHLBWOV.js → chunk-ZCYLCD6L.js} +3 -3
- package/dist/{chunk-ODJQ5KFS.js → chunk-ZXK46QT4.js} +1 -1
- package/dist/{codex-app-cards-switch-JKKTCZNB.js → codex-app-cards-switch-D475JLP5.js} +7 -7
- package/dist/{codex-desktop-launch-6BONFDHF.js → codex-desktop-launch-WOPEZMO6.js} +1 -1
- package/dist/{codex-session-launch-5FRL6UGU.js → codex-session-launch-AWWECV2L.js} +12 -12
- package/dist/{global-upgrade-W4ZTLGUY.js → global-upgrade-V6FA6MUF.js} +8 -8
- package/dist/{identity-2LPVILX4.js → identity-BEPQQHNR.js} +3 -3
- package/dist/{live-capabilities-SWMCZORB.js → live-capabilities-EAM2QN73.js} +4 -4
- package/dist/{paths-4SVXADJ6.js → paths-2IEEYSHM.js} +2 -2
- package/dist/{reapply-WEXL57SG.js → reapply-3LBUA5EV.js} +21 -21
- package/dist/src/index.js +20 -20
- package/dist/{upgrade-lock-NZ2MBGYE.js → upgrade-lock-DLGVC34L.js} +3 -3
- package/package.json +1 -1
|
@@ -5,12 +5,12 @@ import {
|
|
|
5
5
|
hermesAppWiringRecord,
|
|
6
6
|
hermesConfigReceipt,
|
|
7
7
|
hermesSetupReceipt
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-O7ITTNDD.js";
|
|
9
9
|
import {
|
|
10
10
|
readJsonSafe,
|
|
11
11
|
resolvePathBinary,
|
|
12
12
|
writeJsonAtomic
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-BQHZRH6S.js";
|
|
14
14
|
import {
|
|
15
15
|
isNewerVersion,
|
|
16
16
|
isReleaseVersion
|
|
@@ -107,6 +107,7 @@ def register(ctx):
|
|
|
107
107
|
"on_session_start": _on_session_start,
|
|
108
108
|
"on_session_end": _on_session_end,
|
|
109
109
|
"on_session_finalize": _on_session_finalize,
|
|
110
|
+
"post_approval_response": _on_post_approval_response,
|
|
110
111
|
}
|
|
111
112
|
for hook_name, callback in hooks.items():
|
|
112
113
|
ctx.register_hook(hook_name, callback)
|
|
@@ -314,6 +315,15 @@ def _on_pre_tool_call(tool_name=None, args=None, session_id=None, **kwargs):
|
|
|
314
315
|
return _block_unapproved(tool_name)
|
|
315
316
|
|
|
316
317
|
|
|
318
|
+
PUSHARY_TRANSPORT_SURFACE = f"transport:{host.TRANSPORT_NAME}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _on_post_approval_response(choice=None, surface=None, request_id=None, **kwargs):
|
|
322
|
+
if surface == PUSHARY_TRANSPORT_SURFACE and request_id:
|
|
323
|
+
approval.record_host_outcome(str(request_id), choice)
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
317
327
|
def _tool_failed(result, status):
|
|
318
328
|
if status is not None:
|
|
319
329
|
return status == "error"
|
|
@@ -937,7 +947,618 @@ def get_end_user_decision(decision_id, wait_seconds=45):
|
|
|
937
947
|
seg = urllib.parse.quote(str(decision_id), safe="")
|
|
938
948
|
return _rest_call("GET", f"/decisions/{seg}", params={"wait": wait_seconds})
|
|
939
949
|
` },
|
|
940
|
-
{ 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' },
|
|
950
|
+
{ path: "approval_ledger.py", contents: 'from __future__ import annotations\n\nimport json\nimport os\nimport re\nimport secrets\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Literal, Mapping, Optional, Set, Tuple, Union\n\nif TYPE_CHECKING:\n from typing_extensions import TypeGuard\n\nApprovalChoice = Literal["once", "session", "always", "deny"]\nRecordState = Literal["waiting", "settled"]\nClaimState = Literal["claiming", "won", "lost"]\nHandedBack = Literal["handed_to_hermes"]\nEnded = Literal["ended"]\nSettledOutcome = Union[ApprovalChoice, HandedBack]\nRecentOutcome = Union[SettledOutcome, Ended]\nDecider = Literal["hermes_desktop", "pushary_transport"]\nRefusedClaim = Literal["not_found", "not_waiting", "not_answerable_here", "not_offered", "already_claimed"]\nClaimResult = Union[Literal["claimed"], RefusedClaim]\nProcessIsRunning = Callable[[int], bool]\n\nAPPROVAL_CHOICES: Tuple[ApprovalChoice, ...] = ("once", "session", "always", "deny")\nWAITING: RecordState = "waiting"\nSETTLED: RecordState = "settled"\nCLAIMING: ClaimState = "claiming"\nWON: ClaimState = "won"\nLOST: ClaimState = "lost"\nCLAIM_STATES: Tuple[ClaimState, ...] = (CLAIMING, WON, LOST)\nHANDED_TO_HERMES: HandedBack = "handed_to_hermes"\nENDED: Ended = "ended"\nSETTLED_OUTCOMES: Tuple[SettledOutcome, ...] = (*APPROVAL_CHOICES, HANDED_TO_HERMES)\nRECENT_OUTCOMES: Tuple[RecentOutcome, ...] = (*SETTLED_OUTCOMES, ENDED)\nDECIDED_BY_DESKTOP: Decider = "hermes_desktop"\nDECIDED_BY_TRANSPORT: Decider = "pushary_transport"\n\nCLAIMED: Literal["claimed"] = "claimed"\nNOT_FOUND: RefusedClaim = "not_found"\nNOT_WAITING: RefusedClaim = "not_waiting"\nNOT_ANSWERABLE_HERE: RefusedClaim = "not_answerable_here"\nNOT_OFFERED: RefusedClaim = "not_offered"\nALREADY_CLAIMED: RefusedClaim = "already_claimed"\nREFUSED_CLAIMS: Tuple[RefusedClaim, ...] = (NOT_FOUND, NOT_WAITING, NOT_ANSWERABLE_HERE, NOT_OFFERED, ALREADY_CLAIMED)\n\nDESKTOP_ANSWER_SOURCE = "hermes_desktop"\nUNNAMED_ANSWER_SOURCE = "pushary"\n\nLEDGER_PATH = ("plugin-data", "pushary", "approvals")\nRECORD_SUFFIX = ".json"\nHOST_OUTCOME_PREFIX = "host-"\nTEMPORARY_PREFIX = "."\n\nCORRELATION_ID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")\nREQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")\nTOKEN_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,63}$")\n\nRETENTION_SECONDS = 24 * 60 * 60\nRECENT_LIMIT = 6\nSUBJECT_MAX = 200\nLABEL_MAX = 80\n\n\n@dataclass(frozen=True)\nclass OfferedChoice:\n choice: ApprovalChoice\n label: str\n\n\n@dataclass(frozen=True)\nclass OpenedApproval:\n correlation_id: str\n request_id: Optional[str]\n subject: str\n choices: Tuple[OfferedChoice, ...]\n pid: int\n opened_at_ms: int\n deadline_at_ms: int\n\n\n@dataclass(frozen=True)\nclass WaitingRecord:\n opened: OpenedApproval\n\n\n@dataclass(frozen=True)\nclass SettledRecord:\n opened: OpenedApproval\n outcome: SettledOutcome\n answer_source: Optional[str]\n settled_at_ms: int\n\n\nApprovalRecord = Union[WaitingRecord, SettledRecord]\n\n\n@dataclass(frozen=True)\nclass HostOutcome:\n request_id: str\n choice: str\n at_ms: int\n\n\n@dataclass(frozen=True)\nclass WaitingApproval:\n correlation_id: str\n subject: str\n choices: Tuple[OfferedChoice, ...]\n opened_at_ms: int\n deadline_at_ms: int\n answerable_here: bool\n routed_to: Optional[str]\n claim: Optional[ClaimState]\n\n\n@dataclass(frozen=True)\nclass RecentApproval:\n correlation_id: str\n subject: str\n opened_at_ms: int\n settled_at_ms: int\n outcome: RecentOutcome\n answer_source: Optional[str]\n host_outcome: Optional[str]\n\n\n@dataclass(frozen=True)\nclass ApprovalsView:\n waiting: Tuple[WaitingApproval, ...]\n recent: Tuple[RecentApproval, ...]\n\n\ndef is_correlation_id(value: object) -> TypeGuard[str]:\n return isinstance(value, str) and CORRELATION_ID_PATTERN.fullmatch(value) is not None\n\n\ndef is_request_id(value: object) -> TypeGuard[str]:\n return isinstance(value, str) and REQUEST_ID_PATTERN.fullmatch(value) is not None\n\n\ndef is_token(value: object) -> TypeGuard[str]:\n return isinstance(value, str) and TOKEN_PATTERN.fullmatch(value) is not None\n\n\ndef approval_choice(value: object) -> Optional[ApprovalChoice]:\n for choice in APPROVAL_CHOICES:\n if value == choice:\n return choice\n return None\n\n\ndef _record_state(value: object) -> Optional[RecordState]:\n for state in (WAITING, SETTLED):\n if value == state:\n return state\n return None\n\n\ndef _settled_outcome(value: object) -> Optional[SettledOutcome]:\n for outcome in SETTLED_OUTCOMES:\n if value == outcome:\n return outcome\n return None\n\n\ndef _instant(value: object) -> Optional[int]:\n return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None\n\n\ndef _bounded_text(value: object, limit: int) -> Optional[str]:\n return value if isinstance(value, str) and len(value) <= limit else None\n\n\ndef _fields(value: object) -> Optional[Mapping[object, object]]:\n return value if isinstance(value, dict) else None\n\n\ndef offered_choices(pairs: Iterable[Tuple[str, str]]) -> Tuple[OfferedChoice, ...]:\n offered = []\n for raw_choice, raw_label in pairs:\n choice = approval_choice(raw_choice)\n if choice is not None:\n offered.append(OfferedChoice(choice, str(raw_label)[:LABEL_MAX]))\n return tuple(offered)\n\n\ndef _parse_offered(value: object) -> Optional[OfferedChoice]:\n fields = _fields(value)\n if fields is None:\n return None\n choice = approval_choice(fields.get("choice"))\n label = _bounded_text(fields.get("label"), LABEL_MAX)\n return OfferedChoice(choice, label) if choice is not None and label is not None else None\n\n\ndef _parse_choices(value: object) -> Optional[Tuple[OfferedChoice, ...]]:\n if not isinstance(value, list):\n return None\n parsed: List[OfferedChoice] = []\n for item in value:\n offered = _parse_offered(item)\n if offered is None:\n return None\n parsed.append(offered)\n return tuple(parsed)\n\n\ndef _parse_opened(fields: Mapping[object, object]) -> Optional[OpenedApproval]:\n correlation_id = fields.get("correlationId")\n request_id = fields.get("requestId")\n subject = _bounded_text(fields.get("subject"), SUBJECT_MAX)\n choices = _parse_choices(fields.get("choices"))\n pid = fields.get("pid")\n opened_at_ms = _instant(fields.get("openedAtMs"))\n deadline_at_ms = _instant(fields.get("deadlineAtMs"))\n if not is_correlation_id(correlation_id):\n return None\n if request_id is not None and not is_request_id(request_id):\n return None\n if (\n subject is None\n or not choices\n or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0\n or opened_at_ms is None\n or deadline_at_ms is None\n ):\n return None\n return OpenedApproval(\n correlation_id=correlation_id,\n request_id=request_id,\n subject=subject,\n choices=choices,\n pid=pid,\n opened_at_ms=opened_at_ms,\n deadline_at_ms=deadline_at_ms,\n )\n\n\ndef parse_record(value: object) -> Optional[ApprovalRecord]:\n fields = _fields(value)\n if fields is None:\n return None\n opened = _parse_opened(fields)\n state = _record_state(fields.get("state"))\n if opened is None or state is None:\n return None\n if state == WAITING:\n return WaitingRecord(opened)\n outcome = _settled_outcome(fields.get("outcome"))\n settled_at_ms = _instant(fields.get("settledAtMs"))\n answer_source = fields.get("answerSource")\n if outcome is None or settled_at_ms is None:\n return None\n if answer_source is not None and not is_token(answer_source):\n return None\n return SettledRecord(opened, outcome, answer_source, settled_at_ms)\n\n\ndef parse_host_outcome(value: object) -> Optional[HostOutcome]:\n fields = _fields(value)\n if fields is None:\n return None\n request_id = fields.get("requestId")\n choice = fields.get("choice")\n at_ms = _instant(fields.get("atMs"))\n if not is_request_id(request_id) or not is_token(choice) or at_ms is None:\n return None\n return HostOutcome(request_id, choice, at_ms)\n\n\ndef _offered_json(choice: OfferedChoice) -> Dict[str, object]:\n return {"choice": choice.choice, "label": choice.label}\n\n\ndef _opened_json(opened: OpenedApproval) -> Dict[str, object]:\n return {\n "correlationId": opened.correlation_id,\n "requestId": opened.request_id,\n "subject": opened.subject,\n "choices": [_offered_json(choice) for choice in opened.choices],\n "pid": opened.pid,\n "openedAtMs": opened.opened_at_ms,\n "deadlineAtMs": opened.deadline_at_ms,\n }\n\n\ndef _record_json(record: ApprovalRecord) -> Dict[str, object]:\n stored = _opened_json(record.opened)\n if isinstance(record, WaitingRecord):\n stored["state"] = WAITING\n return stored\n stored.update(state=SETTLED, outcome=record.outcome, answerSource=record.answer_source,\n settledAtMs=record.settled_at_ms)\n return stored\n\n\ndef _host_outcome_json(outcome: HostOutcome) -> Dict[str, object]:\n return {"requestId": outcome.request_id, "choice": outcome.choice, "atMs": outcome.at_ms}\n\n\ndef ledger_directory(home: Path) -> Path:\n return Path(home).joinpath(*LEDGER_PATH)\n\n\ndef _record_path(home: Path, correlation_id: str) -> Path:\n return ledger_directory(home) / f"{correlation_id}{RECORD_SUFFIX}"\n\n\ndef _host_outcome_path(home: Path, request_id: str) -> Path:\n return ledger_directory(home) / f"{HOST_OUTCOME_PREFIX}{request_id}{RECORD_SUFFIX}"\n\n\ndef _ensure_parent(path: Path) -> None:\n path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)\n\n\ndef _staged_json(path: Path, value: Dict[str, object]) -> Path:\n _ensure_parent(path)\n staged = path.with_name(f"{TEMPORARY_PREFIX}{path.name}.{secrets.token_hex(8)}.tmp")\n try:\n with open(os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w", encoding="utf-8") as handle:\n json.dump(value, handle)\n except BaseException:\n staged.unlink(missing_ok=True)\n raise\n return staged\n\n\ndef _replace_json(path: Path, value: Dict[str, object]) -> None:\n staged = _staged_json(path, value)\n try:\n os.replace(staged, path)\n finally:\n staged.unlink(missing_ok=True)\n\n\ndef _read_json(path: Path) -> object:\n try:\n return json.loads(path.read_text(encoding="utf-8"))\n except (OSError, ValueError):\n return None\n\n\ndef read_record(home: Path, correlation_id: str) -> Optional[ApprovalRecord]:\n if not is_correlation_id(correlation_id):\n return None\n record = parse_record(_read_json(_record_path(home, correlation_id)))\n return record if record is not None and record.opened.correlation_id == correlation_id else None\n\n\ndef read_host_outcome(home: Path, request_id: Optional[str]) -> Optional[HostOutcome]:\n if not is_request_id(request_id):\n return None\n outcome = parse_host_outcome(_read_json(_host_outcome_path(home, request_id)))\n return outcome if outcome is not None and outcome.request_id == request_id else None\n\n\ndef open_approval(\n home: Path,\n *,\n correlation_id: str,\n request_id: Optional[str],\n subject: str,\n choices: Tuple[OfferedChoice, ...],\n pid: int,\n opened_at_ms: int,\n deadline_at_ms: int,\n) -> bool:\n if not is_correlation_id(correlation_id) or pid <= 0 or not choices:\n return False\n opened = OpenedApproval(\n correlation_id=correlation_id,\n request_id=request_id if is_request_id(request_id) else None,\n subject=subject[:SUBJECT_MAX],\n choices=choices,\n pid=pid,\n opened_at_ms=opened_at_ms,\n deadline_at_ms=deadline_at_ms,\n )\n _replace_json(_record_path(home, correlation_id), _record_json(WaitingRecord(opened)))\n return True\n\n\ndef settle_approval(\n home: Path,\n correlation_id: str,\n *,\n outcome: SettledOutcome,\n answer_source: Optional[str],\n settled_at_ms: int,\n) -> bool:\n record = read_record(home, correlation_id)\n if not isinstance(record, WaitingRecord):\n return False\n source = answer_source if is_token(answer_source) else None\n _replace_json(_record_path(home, correlation_id), _record_json(SettledRecord(record.opened, outcome, source,\n settled_at_ms)))\n return True\n\n\ndef record_host_outcome(home: Path, request_id: str, choice: str, at_ms: int) -> bool:\n if not is_request_id(request_id) or not is_token(choice):\n return False\n _replace_json(_host_outcome_path(home, request_id), _host_outcome_json(HostOutcome(request_id, choice, at_ms)))\n return True\n\n\ndef host_has_finished(home: Path, request_id: Optional[str]) -> bool:\n return read_host_outcome(home, request_id) is not None\n\n\ndef _owner_is_running(opened: OpenedApproval, process_is_running: ProcessIsRunning) -> bool:\n return opened.pid > 0 and process_is_running(opened.pid)\n\n\ndef _is_live(record: ApprovalRecord, host_outcome: Optional[HostOutcome], now_ms: int,\n process_is_running: ProcessIsRunning) -> bool:\n return (\n isinstance(record, WaitingRecord)\n and host_outcome is None\n and now_ms < record.opened.deadline_at_ms\n and _owner_is_running(record.opened, process_is_running)\n )\n\n\ndef _waiting_elsewhere(record: WaitingRecord) -> WaitingApproval:\n opened = record.opened\n return WaitingApproval(\n correlation_id=opened.correlation_id,\n subject=opened.subject,\n choices=opened.choices,\n opened_at_ms=opened.opened_at_ms,\n deadline_at_ms=opened.deadline_at_ms,\n answerable_here=False,\n routed_to=None,\n claim=None,\n )\n\n\ndef _recent(record: ApprovalRecord, host_outcome: Optional[HostOutcome]) -> RecentApproval:\n opened = record.opened\n if isinstance(record, SettledRecord):\n outcome: RecentOutcome = record.outcome\n settled_at_ms = record.settled_at_ms\n answer_source = record.answer_source\n else:\n outcome = ENDED\n settled_at_ms = host_outcome.at_ms if host_outcome else opened.deadline_at_ms\n answer_source = None\n return RecentApproval(\n correlation_id=opened.correlation_id,\n subject=opened.subject,\n opened_at_ms=opened.opened_at_ms,\n settled_at_ms=settled_at_ms,\n outcome=outcome,\n answer_source=answer_source,\n host_outcome=host_outcome.choice if host_outcome else None,\n )\n\n\ndef approvals_view(\n records: Iterable[ApprovalRecord],\n host_outcomes: Mapping[str, HostOutcome],\n now_ms: int,\n process_is_running: ProcessIsRunning,\n) -> ApprovalsView:\n waiting = []\n recent = []\n for record in records:\n request_id = record.opened.request_id\n host_outcome = host_outcomes.get(request_id) if request_id else None\n if isinstance(record, WaitingRecord) and _is_live(record, host_outcome, now_ms, process_is_running):\n waiting.append(_waiting_elsewhere(record))\n else:\n recent.append(_recent(record, host_outcome))\n waiting.sort(key=lambda item: item.opened_at_ms)\n recent.sort(key=lambda item: item.settled_at_ms, reverse=True)\n return ApprovalsView(waiting=tuple(waiting), recent=tuple(recent[:RECENT_LIMIT]))\n\n\ndef _ledger_files(home: Path) -> List[Path]:\n try:\n return [path for path in ledger_directory(home).iterdir() if path.is_file()]\n except OSError:\n return []\n\n\ndef _correlation_id_of(name: str) -> Optional[str]:\n candidate = name[: -len(RECORD_SUFFIX)] if name.endswith(RECORD_SUFFIX) else None\n return candidate if is_correlation_id(candidate) else None\n\n\ndef _ledger_entries(home: Path) -> List[Tuple[Path, object]]:\n return [(path, _read_json(path)) for path in _ledger_files(home) if not path.name.startswith(TEMPORARY_PREFIX)]\n\n\ndef read_approvals(home: Path, now_ms: int, process_is_running: ProcessIsRunning) -> ApprovalsView:\n records: List[ApprovalRecord] = []\n host_outcomes: Dict[str, HostOutcome] = {}\n for path, value in _ledger_entries(home):\n name = path.name\n if name.startswith(HOST_OUTCOME_PREFIX):\n host_outcome = parse_host_outcome(value)\n if host_outcome is not None:\n host_outcomes[host_outcome.request_id] = host_outcome\n else:\n record = parse_record(value)\n if record is not None and name == f"{record.opened.correlation_id}{RECORD_SUFFIX}":\n records.append(record)\n return approvals_view(records, host_outcomes, now_ms, process_is_running)\n\n\ndef _unexpired_waiting(home: Path, now_ms: int) -> Set[str]:\n kept: Set[str] = set()\n for path, value in _ledger_entries(home):\n if path.name.startswith(HOST_OUTCOME_PREFIX) or not path.name.endswith(RECORD_SUFFIX):\n continue\n record = parse_record(value)\n if isinstance(record, WaitingRecord) and record.opened.deadline_at_ms > now_ms:\n kept.add(record.opened.correlation_id)\n return kept\n\n\ndef prune(home: Path, now_seconds: float) -> None:\n kept = _unexpired_waiting(home, int(now_seconds * 1000))\n for path in _ledger_files(home):\n if _correlation_id_of(path.name) in kept:\n continue\n try:\n if now_seconds - path.stat().st_mtime > RETENTION_SECONDS:\n path.unlink(missing_ok=True)\n except OSError:\n continue\n' },
|
|
951
|
+
{ path: "approval.py", contents: `import logging
|
|
952
|
+
import os
|
|
953
|
+
import sys
|
|
954
|
+
import time
|
|
955
|
+
from typing import Literal, Optional, Tuple
|
|
956
|
+
|
|
957
|
+
from . import api, approval_ledger, cancellation, desktop_answers, desktop_contract, identity, installation
|
|
958
|
+
|
|
959
|
+
logger = logging.getLogger(__name__)
|
|
960
|
+
|
|
961
|
+
HERMES_SHELL_TOOL = "terminal"
|
|
962
|
+
DECISION_LINE_MAX = 500
|
|
963
|
+
ACTION_BODY_MAX = 4000
|
|
964
|
+
|
|
965
|
+
POLL_CEILING_SECONDS = 50.0
|
|
966
|
+
WITHDRAW_RESERVE_SECONDS = 10.0
|
|
967
|
+
HOST_RETURN_MARGIN_SECONDS = 1.0
|
|
968
|
+
LATE_ANSWER_POLL_MS = 1000
|
|
969
|
+
|
|
970
|
+
TUI_GATEWAY_MODULE = "tui_gateway.server"
|
|
971
|
+
|
|
972
|
+
RouteAnswerability = Literal["requester", "routed_elsewhere", "unknown"]
|
|
973
|
+
ANSWERABLE_BY_REQUESTER: RouteAnswerability = "requester"
|
|
974
|
+
ROUTED_ELSEWHERE: RouteAnswerability = "routed_elsewhere"
|
|
975
|
+
ANSWERABILITY_UNKNOWN: RouteAnswerability = "unknown"
|
|
976
|
+
|
|
977
|
+
Withdrawal = Literal["withdrawn", "answered_late", "ended", "stopped"]
|
|
978
|
+
WITHDRAWN: Withdrawal = "withdrawn"
|
|
979
|
+
ANSWERED_LATE: Withdrawal = "answered_late"
|
|
980
|
+
ENDED_UNANSWERED: Withdrawal = "ended"
|
|
981
|
+
STOPPED: Withdrawal = "stopped"
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
CHOICE_LABELS = (
|
|
985
|
+
("once", "Allow once"),
|
|
986
|
+
("session", "Allow for this session"),
|
|
987
|
+
("always", "Always allow"),
|
|
988
|
+
("deny", "Deny"),
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
class TransportUnavailable(Exception):
|
|
993
|
+
pass
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
QUESTION_COMMAND_MAX = 160
|
|
997
|
+
|
|
998
|
+
PLUGIN_RULE_MARKER = "(plugin approval rule)"
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _offered(allowed):
|
|
1002
|
+
return [pair for pair in CHOICE_LABELS if pair[0] in set(allowed or ())]
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _command_head(command):
|
|
1006
|
+
return " ".join(str(command or "").split()[:2])[:80]
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _command_summary(command):
|
|
1010
|
+
collapsed = " ".join(str(command or "").split())
|
|
1011
|
+
if len(collapsed) <= QUESTION_COMMAND_MAX:
|
|
1012
|
+
return collapsed
|
|
1013
|
+
return collapsed[:QUESTION_COMMAND_MAX - 1] + "\u2026"
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _choice_from_answer(value, offered, uses_select):
|
|
1017
|
+
answer = str(value or "").strip()
|
|
1018
|
+
if uses_select:
|
|
1019
|
+
for choice, label in offered:
|
|
1020
|
+
if answer.lower() == label.lower():
|
|
1021
|
+
return choice
|
|
1022
|
+
return "deny"
|
|
1023
|
+
return "once" if api.is_affirmative(answer) else "deny"
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _subject(request):
|
|
1027
|
+
"""What to put in the question.
|
|
1028
|
+
|
|
1029
|
+
A dangerous-command approval carries the real command. A plugin rule
|
|
1030
|
+
escalated from pre_tool_call carries a synthetic display label instead, and
|
|
1031
|
+
the plugin's own message is the part worth reading, so use that.
|
|
1032
|
+
"""
|
|
1033
|
+
command = str(request.command or "")
|
|
1034
|
+
if PLUGIN_RULE_MARKER in command:
|
|
1035
|
+
lines = str(request.description or "").strip().splitlines()
|
|
1036
|
+
if lines and lines[0].strip():
|
|
1037
|
+
return _command_summary(lines[0])
|
|
1038
|
+
return _command_summary(command)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _open_question(request, offered, uses_select):
|
|
1042
|
+
command = str(request.command or "")
|
|
1043
|
+
return api.ask_user(
|
|
1044
|
+
question=f"Allow Hermes to run \`{_subject(request)}\`?",
|
|
1045
|
+
question_type="select" if uses_select else "confirm",
|
|
1046
|
+
options=[label for _, label in offered] if uses_select else None,
|
|
1047
|
+
context=str(request.description or "")[:500] or None,
|
|
1048
|
+
agent_name=identity.agent_name(),
|
|
1049
|
+
session_id=identity.session_id(),
|
|
1050
|
+
machine_id=identity.machine_id(),
|
|
1051
|
+
tool_name=HERMES_SHELL_TOOL,
|
|
1052
|
+
tool_target=_command_head(command),
|
|
1053
|
+
action=command[:DECISION_LINE_MAX] or None,
|
|
1054
|
+
action_body=command[:ACTION_BODY_MAX] or None,
|
|
1055
|
+
blocker=(
|
|
1056
|
+
f"Hermes flagged this command as {request.pattern_key}"[:DECISION_LINE_MAX]
|
|
1057
|
+
if request.pattern_key
|
|
1058
|
+
else None
|
|
1059
|
+
),
|
|
1060
|
+
wait=False,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _stopped(result):
|
|
1065
|
+
return result.get("handoffAction") == cancellation.STOP_HANDOFF or result.get("status") in (
|
|
1066
|
+
"cancelled", cancellation.UNAVAILABLE_STATUS, "stopped")
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _route_answerability(created) -> RouteAnswerability:
|
|
1070
|
+
requester_may_answer = created.get("requesterMayAnswer")
|
|
1071
|
+
if requester_may_answer is True:
|
|
1072
|
+
return ANSWERABLE_BY_REQUESTER
|
|
1073
|
+
if requester_may_answer is False:
|
|
1074
|
+
return ROUTED_ELSEWHERE
|
|
1075
|
+
return ANSWERABILITY_UNKNOWN
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _routed_to(created) -> Optional[str]:
|
|
1079
|
+
routed_to = created.get("routedTo")
|
|
1080
|
+
return routed_to if isinstance(routed_to, str) and routed_to.strip() else None
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _withdraw(correlation_id) -> Tuple[Withdrawal, dict]:
|
|
1084
|
+
try:
|
|
1085
|
+
result = cancellation.classify_cancellation(api.cancel_question(correlation_id))
|
|
1086
|
+
if result in (cancellation.WITHDRAWN_BY_THIS_CALL, cancellation.CANCELLED_UNPROVEN):
|
|
1087
|
+
return WITHDRAWN, {}
|
|
1088
|
+
if result == cancellation.NOT_PENDING:
|
|
1089
|
+
answer = api.wait_for_answer(correlation_id, LATE_ANSWER_POLL_MS)
|
|
1090
|
+
if answer.get("answered"):
|
|
1091
|
+
return ANSWERED_LATE, answer
|
|
1092
|
+
if answer.get("status") in ("expired", "missing"):
|
|
1093
|
+
return ENDED_UNANSWERED, answer
|
|
1094
|
+
except Exception:
|
|
1095
|
+
pass
|
|
1096
|
+
return STOPPED, {}
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _wall_clock_ms():
|
|
1100
|
+
return int(time.time() * 1000)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def announce_approvals_changed() -> None:
|
|
1104
|
+
if TUI_GATEWAY_MODULE not in sys.modules:
|
|
1105
|
+
return
|
|
1106
|
+
try:
|
|
1107
|
+
from hermes_cli.plugin_events import broadcast_plugin_event
|
|
1108
|
+
except Exception:
|
|
1109
|
+
return
|
|
1110
|
+
try:
|
|
1111
|
+
broadcast_plugin_event(desktop_contract.PLUGIN_ID, desktop_contract.APPROVALS_CHANGED_EVENT, {})
|
|
1112
|
+
except Exception:
|
|
1113
|
+
logger.debug("[pushary] could not tell Hermes Desktop about an approval", exc_info=True)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _open_answer_slot(request, correlation_id: str, offered, answerability: RouteAnswerability,
|
|
1117
|
+
routed_to: Optional[str], opened_at_ms: int, deadline_at_ms: int) -> None:
|
|
1118
|
+
try:
|
|
1119
|
+
desktop_answers.shared_answers().open_slot(
|
|
1120
|
+
correlation_id,
|
|
1121
|
+
request_id=getattr(request, "request_id", None),
|
|
1122
|
+
subject=_subject(request),
|
|
1123
|
+
choices=approval_ledger.offered_choices(offered),
|
|
1124
|
+
opened_at_ms=opened_at_ms,
|
|
1125
|
+
deadline_at_ms=deadline_at_ms,
|
|
1126
|
+
answerable_here=answerability == ANSWERABLE_BY_REQUESTER,
|
|
1127
|
+
routed_to=routed_to,
|
|
1128
|
+
)
|
|
1129
|
+
except Exception:
|
|
1130
|
+
logger.debug("[pushary] could not offer an approval to Hermes Desktop", exc_info=True)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _close_answer_slot(correlation_id: str) -> None:
|
|
1134
|
+
try:
|
|
1135
|
+
desktop_answers.shared_answers().close_slot(correlation_id)
|
|
1136
|
+
except Exception:
|
|
1137
|
+
logger.debug("[pushary] could not withdraw an approval from Hermes Desktop", exc_info=True)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _record_opened(request, correlation_id: str, offered, answerability: RouteAnswerability,
|
|
1141
|
+
routed_to: Optional[str]) -> None:
|
|
1142
|
+
opened_at_ms = _wall_clock_ms()
|
|
1143
|
+
deadline_at_ms = opened_at_ms + int(float(request.timeout_seconds or 0) * 1000)
|
|
1144
|
+
_open_answer_slot(request, correlation_id, offered, answerability, routed_to, opened_at_ms, deadline_at_ms)
|
|
1145
|
+
try:
|
|
1146
|
+
home = installation.hermes_home()
|
|
1147
|
+
approval_ledger.prune(home, time.time())
|
|
1148
|
+
opened = approval_ledger.open_approval(
|
|
1149
|
+
home,
|
|
1150
|
+
correlation_id=correlation_id,
|
|
1151
|
+
request_id=getattr(request, "request_id", None),
|
|
1152
|
+
subject=_subject(request),
|
|
1153
|
+
choices=approval_ledger.offered_choices(offered),
|
|
1154
|
+
pid=os.getpid(),
|
|
1155
|
+
opened_at_ms=opened_at_ms,
|
|
1156
|
+
deadline_at_ms=deadline_at_ms,
|
|
1157
|
+
)
|
|
1158
|
+
except Exception:
|
|
1159
|
+
logger.debug("[pushary] could not record an open approval", exc_info=True)
|
|
1160
|
+
opened = False
|
|
1161
|
+
if opened:
|
|
1162
|
+
announce_approvals_changed()
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _record_settled(correlation_id: str, outcome, answer_source: Optional[str]) -> None:
|
|
1166
|
+
try:
|
|
1167
|
+
settled = approval_ledger.settle_approval(
|
|
1168
|
+
installation.hermes_home(),
|
|
1169
|
+
correlation_id,
|
|
1170
|
+
outcome=outcome,
|
|
1171
|
+
answer_source=answer_source,
|
|
1172
|
+
settled_at_ms=_wall_clock_ms(),
|
|
1173
|
+
)
|
|
1174
|
+
except Exception:
|
|
1175
|
+
logger.debug("[pushary] could not record a settled approval", exc_info=True)
|
|
1176
|
+
return
|
|
1177
|
+
if settled:
|
|
1178
|
+
announce_approvals_changed()
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def record_host_outcome(request_id, choice) -> None:
|
|
1182
|
+
try:
|
|
1183
|
+
desktop_answers.shared_answers().end_for_request(request_id)
|
|
1184
|
+
except Exception:
|
|
1185
|
+
logger.debug("[pushary] could not end an approval in Hermes Desktop", exc_info=True)
|
|
1186
|
+
try:
|
|
1187
|
+
recorded = approval_ledger.record_host_outcome(installation.hermes_home(), request_id, choice, _wall_clock_ms())
|
|
1188
|
+
except Exception:
|
|
1189
|
+
logger.debug("[pushary] could not record Hermes's approval outcome", exc_info=True)
|
|
1190
|
+
return
|
|
1191
|
+
if recorded:
|
|
1192
|
+
announce_approvals_changed()
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _decided(request, correlation_id, choice, answer_source):
|
|
1196
|
+
_record_settled(correlation_id, choice, answer_source)
|
|
1197
|
+
return request.respond(choice)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _answer_source(answer) -> str:
|
|
1201
|
+
source = answer.get("answerSource")
|
|
1202
|
+
return source if approval_ledger.is_token(source) else approval_ledger.UNNAMED_ANSWER_SOURCE
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _answered(request, correlation_id, answer, offered, uses_select):
|
|
1206
|
+
choice = _choice_from_answer(answer.get("value"), offered, uses_select)
|
|
1207
|
+
return _decided(request, correlation_id, choice, _answer_source(answer))
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _offered_choices(offered) -> Tuple[str, ...]:
|
|
1211
|
+
return tuple(choice for choice, _ in offered)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _host_has_finished(request, correlation_id: str) -> bool:
|
|
1215
|
+
try:
|
|
1216
|
+
if desktop_answers.shared_answers().has_ended(correlation_id):
|
|
1217
|
+
return True
|
|
1218
|
+
return approval_ledger.host_has_finished(installation.hermes_home(), getattr(request, "request_id", None))
|
|
1219
|
+
except Exception:
|
|
1220
|
+
return False
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _desktop_decision(correlation_id: str, offered) -> Optional[str]:
|
|
1224
|
+
try:
|
|
1225
|
+
return desktop_answers.shared_answers().desktop_decision(correlation_id, _offered_choices(offered))
|
|
1226
|
+
except Exception:
|
|
1227
|
+
logger.debug("[pushary] could not read a Hermes Desktop answer", exc_info=True)
|
|
1228
|
+
return None
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _final_say(correlation_id: str, offered, host_deadline: float, claim_may_still_win: bool) -> Optional[str]:
|
|
1232
|
+
wait = min(desktop_answers.CLAIM_RESOLVE_SECONDS, host_deadline - time.monotonic()) if claim_may_still_win else 0.0
|
|
1233
|
+
try:
|
|
1234
|
+
return desktop_answers.shared_answers().take_final_say(correlation_id, _offered_choices(offered),
|
|
1235
|
+
claim_wait_seconds=wait)
|
|
1236
|
+
except Exception:
|
|
1237
|
+
logger.debug("[pushary] could not settle who answers this approval", exc_info=True)
|
|
1238
|
+
return None
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def present(request):
|
|
1242
|
+
offered = _offered(request.allowed_choices)
|
|
1243
|
+
if not offered:
|
|
1244
|
+
return request.respond("deny")
|
|
1245
|
+
uses_select = [choice for choice, _ in offered] != ["once", "deny"]
|
|
1246
|
+
# Reserve time for withdrawal before the host discards a late decision.
|
|
1247
|
+
started_at = time.monotonic()
|
|
1248
|
+
timeout_seconds = float(request.timeout_seconds or 0)
|
|
1249
|
+
deadline = started_at + max(timeout_seconds - WITHDRAW_RESERVE_SECONDS, 0.0)
|
|
1250
|
+
host_deadline = started_at + max(timeout_seconds - HOST_RETURN_MARGIN_SECONDS, 0.0)
|
|
1251
|
+
created = _open_question(request, offered, uses_select)
|
|
1252
|
+
if created.get("answered"):
|
|
1253
|
+
return request.respond(_choice_from_answer(created.get("value"), offered, uses_select))
|
|
1254
|
+
if _stopped(created):
|
|
1255
|
+
return request.respond("deny")
|
|
1256
|
+
correlation_id = created.get("correlationId")
|
|
1257
|
+
if not correlation_id:
|
|
1258
|
+
raise TransportUnavailable("Pushary did not create an approval")
|
|
1259
|
+
answerability = _route_answerability(created)
|
|
1260
|
+
_record_opened(request, correlation_id, offered, answerability, _routed_to(created))
|
|
1261
|
+
try:
|
|
1262
|
+
return _await_decision(request, created, correlation_id, offered, uses_select, answerability,
|
|
1263
|
+
started_at, deadline, host_deadline)
|
|
1264
|
+
finally:
|
|
1265
|
+
_close_answer_slot(correlation_id)
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _await_decision(request, created, correlation_id: str, offered, uses_select: bool,
|
|
1269
|
+
answerability: RouteAnswerability, started_at: float, deadline: float, host_deadline: float):
|
|
1270
|
+
may_hand_back = answerability != ROUTED_ELSEWHERE
|
|
1271
|
+
nothing_was_delivered = bool(created.get("noDevices") or created.get("status") in ("terminal", "notified"))
|
|
1272
|
+
policy_deadline = None
|
|
1273
|
+
window_ms = created.get("policyTimeoutMs")
|
|
1274
|
+
if may_hand_back and created.get("deliveryMode") == "push_first" and isinstance(window_ms, (int, float)) and window_ms >= 0:
|
|
1275
|
+
policy_deadline = started_at + window_ms / 1000
|
|
1276
|
+
deadline = min(deadline, policy_deadline)
|
|
1277
|
+
handoff = may_hand_back and (nothing_was_delivered or bool(created.get("suppressed")))
|
|
1278
|
+
nobody_can_answer = not may_hand_back and nothing_was_delivered
|
|
1279
|
+
answer = created
|
|
1280
|
+
try:
|
|
1281
|
+
while not handoff and not nobody_can_answer and time.monotonic() < deadline:
|
|
1282
|
+
if _host_has_finished(request, correlation_id):
|
|
1283
|
+
answer = {"handoffAction": "stop"}
|
|
1284
|
+
break
|
|
1285
|
+
remaining = deadline - time.monotonic()
|
|
1286
|
+
answer = api.wait_for_answer(correlation_id, int(min(POLL_CEILING_SECONDS, max(1.0, remaining)) * 1000))
|
|
1287
|
+
if answer.get("answered"):
|
|
1288
|
+
return _answered(request, correlation_id, answer, offered, uses_select)
|
|
1289
|
+
if _stopped(answer) or answer.get("error") or answer.get("status") in ("expired", "missing"):
|
|
1290
|
+
break
|
|
1291
|
+
# Some hosts/proxies return pending immediately instead of long-polling.
|
|
1292
|
+
time.sleep(min(0.1, max(0, deadline - time.monotonic())))
|
|
1293
|
+
except Exception:
|
|
1294
|
+
answer = {"handoffAction": "stop"}
|
|
1295
|
+
|
|
1296
|
+
desktop = _desktop_decision(correlation_id, offered)
|
|
1297
|
+
if desktop:
|
|
1298
|
+
return _decided(request, correlation_id, desktop, approval_ledger.DESKTOP_ANSWER_SOURCE)
|
|
1299
|
+
withdrawal, late = _withdraw(correlation_id)
|
|
1300
|
+
if withdrawal == ANSWERED_LATE:
|
|
1301
|
+
return _answered(request, correlation_id, late, offered, uses_select)
|
|
1302
|
+
desktop = _final_say(correlation_id, offered, host_deadline, claim_may_still_win=withdrawal == STOPPED)
|
|
1303
|
+
if desktop:
|
|
1304
|
+
return _decided(request, correlation_id, desktop, approval_ledger.DESKTOP_ANSWER_SOURCE)
|
|
1305
|
+
if _stopped(answer) or withdrawal == STOPPED:
|
|
1306
|
+
return _decided(request, correlation_id, "deny", None)
|
|
1307
|
+
if handoff or (policy_deadline is not None and time.monotonic() >= policy_deadline):
|
|
1308
|
+
# Only a fenced question may reach Hermes's configured builtin fallback.
|
|
1309
|
+
_record_settled(correlation_id, approval_ledger.HANDED_TO_HERMES, None)
|
|
1310
|
+
raise TransportUnavailable("Approval handed back to Hermes")
|
|
1311
|
+
return _decided(request, correlation_id, "deny", None)
|
|
1312
|
+
` },
|
|
1313
|
+
{ path: "cancellation.py", contents: 'from __future__ import annotations\n\nfrom typing import Literal, Mapping, Tuple\n\nCancellation = Literal[\n "withdrawn_by_this_call",\n "cancelled_unproven",\n "already_cancelled",\n "not_pending",\n "unreachable",\n]\n\nWITHDRAWN_BY_THIS_CALL: Cancellation = "withdrawn_by_this_call"\nCANCELLED_UNPROVEN: Cancellation = "cancelled_unproven"\nALREADY_CANCELLED: Cancellation = "already_cancelled"\nNOT_PENDING: Cancellation = "not_pending"\nUNREACHABLE: Cancellation = "unreachable"\nCANCELLATIONS: Tuple[Cancellation, ...] = (\n WITHDRAWN_BY_THIS_CALL,\n CANCELLED_UNPROVEN,\n ALREADY_CANCELLED,\n NOT_PENDING,\n UNREACHABLE,\n)\n\nUNAVAILABLE_STATUS = "unavailable"\nSTOP_HANDOFF = "stop"\n\n\ndef classify_cancellation(response: object) -> Cancellation:\n if not isinstance(response, Mapping):\n return UNREACHABLE\n fields: Mapping[object, object] = response\n cancelled = fields.get("cancelled")\n by_this_call = fields.get("cancelledByThisCall")\n if (\n not isinstance(cancelled, bool)\n or fields.get("status") == UNAVAILABLE_STATUS\n or fields.get("handoffAction") == STOP_HANDOFF\n or (by_this_call is not None and not isinstance(by_this_call, bool))\n or (by_this_call is True and not cancelled)\n ):\n return UNREACHABLE\n if not cancelled:\n return NOT_PENDING\n if by_this_call is None:\n return CANCELLED_UNPROVEN\n return WITHDRAWN_BY_THIS_CALL if by_this_call else ALREADY_CANCELLED\n' },
|
|
1314
|
+
{ path: "dashboard/manifest.json", contents: '{\n "name": "pushary",\n "label": "Pushary",\n "description": "Approvals Pushary is carrying to your phone, answerable from Hermes Desktop",\n "version": "1.0.0",\n "tab": { "hidden": true },\n "entry": "web.js",\n "api": "plugin_api.py"\n}\n' },
|
|
1315
|
+
{ path: "dashboard/plugin_api.py", contents: `import importlib
|
|
1316
|
+
import sys
|
|
1317
|
+
import time
|
|
1318
|
+
import types
|
|
1319
|
+
from pathlib import Path
|
|
1320
|
+
from typing import Any
|
|
1321
|
+
|
|
1322
|
+
from fastapi import APIRouter, Body, HTTPException
|
|
1323
|
+
|
|
1324
|
+
PLUGIN_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
1325
|
+
BACKEND_PACKAGE = "pushary_plugin_desktop_backend"
|
|
1326
|
+
INVALID_ANSWER = 'Send {"choice": one of the choices Hermes offered}'
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _register_plugin_package() -> None:
|
|
1330
|
+
package = types.ModuleType(BACKEND_PACKAGE)
|
|
1331
|
+
package.__path__ = [str(PLUGIN_DIRECTORY)]
|
|
1332
|
+
sys.modules[BACKEND_PACKAGE] = package
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
_register_plugin_package()
|
|
1336
|
+
api = importlib.import_module(f"{BACKEND_PACKAGE}.api")
|
|
1337
|
+
approval = importlib.import_module(f"{BACKEND_PACKAGE}.approval")
|
|
1338
|
+
approval_ledger = importlib.import_module(f"{BACKEND_PACKAGE}.approval_ledger")
|
|
1339
|
+
desktop_answers = importlib.import_module(f"{BACKEND_PACKAGE}.desktop_answers")
|
|
1340
|
+
contract = importlib.import_module(f"{BACKEND_PACKAGE}.desktop_contract")
|
|
1341
|
+
host = importlib.import_module(f"{BACKEND_PACKAGE}.host")
|
|
1342
|
+
installation = importlib.import_module(f"{BACKEND_PACKAGE}.installation")
|
|
1343
|
+
|
|
1344
|
+
router = APIRouter()
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def _now_ms() -> int:
|
|
1348
|
+
return int(time.time() * 1000)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
@router.get(contract.APPROVALS_PATH)
|
|
1352
|
+
def approvals():
|
|
1353
|
+
now_ms = _now_ms()
|
|
1354
|
+
recorded = approval_ledger.read_approvals(installation.hermes_home(), now_ms, installation.process_is_running)
|
|
1355
|
+
view = contract.view_with_answers_here(recorded, desktop_answers.shared_answers().waiting_here(now_ms))
|
|
1356
|
+
setup = contract.setup_wire(
|
|
1357
|
+
api_key_saved=api.is_configured(),
|
|
1358
|
+
transport_selected=host.pushary_is_selected_transport(),
|
|
1359
|
+
plugin_version=installation.LOADED_PLUGIN_VERSION,
|
|
1360
|
+
)
|
|
1361
|
+
return contract.approvals_response(view, setup)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
@router.post(f"{contract.APPROVALS_PATH}/{{correlation_id}}{contract.ANSWER_PATH_SUFFIX}")
|
|
1365
|
+
def answer(correlation_id: str, body: Any = Body(default=None)):
|
|
1366
|
+
choice = contract.parse_answer_request(body)
|
|
1367
|
+
if choice is None or not approval_ledger.is_correlation_id(correlation_id):
|
|
1368
|
+
raise HTTPException(status_code=400, detail=INVALID_ANSWER)
|
|
1369
|
+
response = contract.answer_from_desktop(
|
|
1370
|
+
desktop_answers.shared_answers(),
|
|
1371
|
+
correlation_id,
|
|
1372
|
+
choice,
|
|
1373
|
+
cancel_question=api.cancel_question,
|
|
1374
|
+
now_ms=_now_ms(),
|
|
1375
|
+
)
|
|
1376
|
+
approval.announce_approvals_changed()
|
|
1377
|
+
return response
|
|
1378
|
+
` },
|
|
1379
|
+
{ path: "dashboard/web.js", contents: "(function registerPusharyDashboardPlugin() {\n var sdk = window.__HERMES_PLUGIN_SDK__\n var plugins = window.__HERMES_PLUGINS__\n if (!sdk || !plugins) return\n var h = sdk.React.createElement\n\n function PusharyDashboardNote() {\n return h('p', { style: { padding: '16px', color: 'var(--color-muted-foreground)' } },\n 'Pushary approvals show in the Hermes Desktop status bar and on your phone.')\n }\n\n plugins.register('pushary', PusharyDashboardNote)\n})()\n" },
|
|
1380
|
+
{ path: "desktop_answers.py", contents: `from __future__ import annotations
|
|
1381
|
+
|
|
1382
|
+
import sys
|
|
1383
|
+
import threading
|
|
1384
|
+
import types
|
|
1385
|
+
from typing import Dict, Iterable, List, Optional, Tuple, TypedDict
|
|
1386
|
+
|
|
1387
|
+
from . import approval_ledger as ledger
|
|
1388
|
+
|
|
1389
|
+
SHARED_STATE_MODULE = "pushary_plugin_desktop_answers"
|
|
1390
|
+
CLAIM_RESOLVE_SECONDS = 3.0
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
class AnswerSlot(TypedDict):
|
|
1394
|
+
request_id: Optional[str]
|
|
1395
|
+
subject: str
|
|
1396
|
+
choices: List[Tuple[ledger.ApprovalChoice, str]]
|
|
1397
|
+
opened_at_ms: int
|
|
1398
|
+
deadline_at_ms: int
|
|
1399
|
+
answerable_here: bool
|
|
1400
|
+
routed_to: Optional[str]
|
|
1401
|
+
claim: Optional[ledger.ClaimState]
|
|
1402
|
+
decided_by: Optional[ledger.Decider]
|
|
1403
|
+
desktop_choice: Optional[ledger.ApprovalChoice]
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
class DesktopAnswers:
|
|
1407
|
+
def __init__(self, condition: threading.Condition, slots: Dict[str, AnswerSlot]) -> None:
|
|
1408
|
+
self._condition = condition
|
|
1409
|
+
self._slots = slots
|
|
1410
|
+
|
|
1411
|
+
def open_slot(
|
|
1412
|
+
self,
|
|
1413
|
+
correlation_id: str,
|
|
1414
|
+
*,
|
|
1415
|
+
request_id: Optional[str],
|
|
1416
|
+
subject: str,
|
|
1417
|
+
choices: Tuple[ledger.OfferedChoice, ...],
|
|
1418
|
+
opened_at_ms: int,
|
|
1419
|
+
deadline_at_ms: int,
|
|
1420
|
+
answerable_here: bool,
|
|
1421
|
+
routed_to: Optional[str],
|
|
1422
|
+
) -> None:
|
|
1423
|
+
slot: AnswerSlot = {
|
|
1424
|
+
"request_id": request_id,
|
|
1425
|
+
"subject": subject,
|
|
1426
|
+
"choices": [(offered.choice, offered.label) for offered in choices],
|
|
1427
|
+
"opened_at_ms": opened_at_ms,
|
|
1428
|
+
"deadline_at_ms": deadline_at_ms,
|
|
1429
|
+
"answerable_here": answerable_here,
|
|
1430
|
+
"routed_to": routed_to,
|
|
1431
|
+
"claim": None,
|
|
1432
|
+
"decided_by": None,
|
|
1433
|
+
"desktop_choice": None,
|
|
1434
|
+
}
|
|
1435
|
+
with self._condition:
|
|
1436
|
+
self._slots[correlation_id] = slot
|
|
1437
|
+
self._condition.notify_all()
|
|
1438
|
+
|
|
1439
|
+
def close_slot(self, correlation_id: str) -> None:
|
|
1440
|
+
with self._condition:
|
|
1441
|
+
self._slots.pop(correlation_id, None)
|
|
1442
|
+
self._condition.notify_all()
|
|
1443
|
+
|
|
1444
|
+
def end_for_request(self, request_id: str) -> None:
|
|
1445
|
+
with self._condition:
|
|
1446
|
+
for slot in self._slots.values():
|
|
1447
|
+
if slot["request_id"] == request_id and slot["decided_by"] is None:
|
|
1448
|
+
slot["decided_by"] = ledger.DECIDED_BY_TRANSPORT
|
|
1449
|
+
self._condition.notify_all()
|
|
1450
|
+
|
|
1451
|
+
def has_ended(self, correlation_id: str) -> bool:
|
|
1452
|
+
with self._condition:
|
|
1453
|
+
slot = self._slots.get(correlation_id)
|
|
1454
|
+
return slot is not None and slot["decided_by"] is not None
|
|
1455
|
+
|
|
1456
|
+
def claim(self, correlation_id: str, choice: ledger.ApprovalChoice, *, now_ms: int) -> ledger.ClaimResult:
|
|
1457
|
+
with self._condition:
|
|
1458
|
+
slot = self._slots.get(correlation_id)
|
|
1459
|
+
if slot is None:
|
|
1460
|
+
return ledger.NOT_FOUND
|
|
1461
|
+
if slot["decided_by"] is not None or now_ms >= slot["deadline_at_ms"]:
|
|
1462
|
+
return ledger.NOT_WAITING
|
|
1463
|
+
if not slot["answerable_here"]:
|
|
1464
|
+
return ledger.NOT_ANSWERABLE_HERE
|
|
1465
|
+
if all(offered != choice for offered, _ in slot["choices"]):
|
|
1466
|
+
return ledger.NOT_OFFERED
|
|
1467
|
+
if slot["claim"] is not None:
|
|
1468
|
+
return ledger.ALREADY_CLAIMED
|
|
1469
|
+
slot["claim"] = ledger.CLAIMING
|
|
1470
|
+
return ledger.CLAIMED
|
|
1471
|
+
|
|
1472
|
+
def decide_from_desktop(self, correlation_id: str, choice: ledger.ApprovalChoice) -> bool:
|
|
1473
|
+
with self._condition:
|
|
1474
|
+
slot = self._slots.get(correlation_id)
|
|
1475
|
+
won = slot is not None and slot["decided_by"] is None and slot["claim"] == ledger.CLAIMING
|
|
1476
|
+
if slot is not None and won:
|
|
1477
|
+
slot["decided_by"] = ledger.DECIDED_BY_DESKTOP
|
|
1478
|
+
slot["desktop_choice"] = choice
|
|
1479
|
+
slot["claim"] = ledger.WON
|
|
1480
|
+
elif slot is not None and slot["claim"] == ledger.CLAIMING:
|
|
1481
|
+
slot["claim"] = ledger.LOST
|
|
1482
|
+
self._condition.notify_all()
|
|
1483
|
+
return won
|
|
1484
|
+
|
|
1485
|
+
def lose_claim(self, correlation_id: str) -> None:
|
|
1486
|
+
with self._condition:
|
|
1487
|
+
slot = self._slots.get(correlation_id)
|
|
1488
|
+
if slot is not None and slot["claim"] == ledger.CLAIMING:
|
|
1489
|
+
slot["claim"] = ledger.LOST
|
|
1490
|
+
self._condition.notify_all()
|
|
1491
|
+
|
|
1492
|
+
def desktop_decision(self, correlation_id: str, offered: Iterable[ledger.ApprovalChoice]) -> Optional[ledger.ApprovalChoice]:
|
|
1493
|
+
with self._condition:
|
|
1494
|
+
return self._desktop_choice_locked(correlation_id, tuple(offered))
|
|
1495
|
+
|
|
1496
|
+
def take_final_say(
|
|
1497
|
+
self,
|
|
1498
|
+
correlation_id: str,
|
|
1499
|
+
offered: Iterable[ledger.ApprovalChoice],
|
|
1500
|
+
*,
|
|
1501
|
+
claim_wait_seconds: float,
|
|
1502
|
+
) -> Optional[ledger.ApprovalChoice]:
|
|
1503
|
+
allowed = tuple(offered)
|
|
1504
|
+
with self._condition:
|
|
1505
|
+
self._condition.wait_for(lambda: not self._claim_is_being_fenced(correlation_id),
|
|
1506
|
+
timeout=max(0.0, claim_wait_seconds))
|
|
1507
|
+
slot = self._slots.get(correlation_id)
|
|
1508
|
+
if slot is not None and slot["decided_by"] is None:
|
|
1509
|
+
slot["decided_by"] = ledger.DECIDED_BY_TRANSPORT
|
|
1510
|
+
self._condition.notify_all()
|
|
1511
|
+
return None
|
|
1512
|
+
return self._desktop_choice_locked(correlation_id, allowed)
|
|
1513
|
+
|
|
1514
|
+
def waiting_here(self, now_ms: int) -> Tuple[ledger.WaitingApproval, ...]:
|
|
1515
|
+
with self._condition:
|
|
1516
|
+
live = [(correlation_id, slot.copy()) for correlation_id, slot in self._slots.items()
|
|
1517
|
+
if slot["decided_by"] != ledger.DECIDED_BY_TRANSPORT and now_ms < slot["deadline_at_ms"]]
|
|
1518
|
+
return tuple(_waiting(correlation_id, slot) for correlation_id, slot in live)
|
|
1519
|
+
|
|
1520
|
+
def _claim_is_being_fenced(self, correlation_id: str) -> bool:
|
|
1521
|
+
slot = self._slots.get(correlation_id)
|
|
1522
|
+
return slot is not None and slot["decided_by"] is None and slot["claim"] == ledger.CLAIMING
|
|
1523
|
+
|
|
1524
|
+
def _desktop_choice_locked(
|
|
1525
|
+
self, correlation_id: str, allowed: Tuple[ledger.ApprovalChoice, ...]
|
|
1526
|
+
) -> Optional[ledger.ApprovalChoice]:
|
|
1527
|
+
slot = self._slots.get(correlation_id)
|
|
1528
|
+
if slot is None or slot["decided_by"] != ledger.DECIDED_BY_DESKTOP or not slot["answerable_here"]:
|
|
1529
|
+
return None
|
|
1530
|
+
choice = slot["desktop_choice"]
|
|
1531
|
+
return choice if choice in allowed else None
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _waiting(correlation_id: str, slot: AnswerSlot) -> ledger.WaitingApproval:
|
|
1535
|
+
return ledger.WaitingApproval(
|
|
1536
|
+
correlation_id=correlation_id,
|
|
1537
|
+
subject=slot["subject"],
|
|
1538
|
+
choices=tuple(ledger.OfferedChoice(choice, label) for choice, label in slot["choices"]),
|
|
1539
|
+
opened_at_ms=slot["opened_at_ms"],
|
|
1540
|
+
deadline_at_ms=slot["deadline_at_ms"],
|
|
1541
|
+
answerable_here=slot["answerable_here"],
|
|
1542
|
+
routed_to=slot["routed_to"],
|
|
1543
|
+
claim=slot["claim"],
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def shared_answers() -> DesktopAnswers:
|
|
1548
|
+
holder = sys.modules.get(SHARED_STATE_MODULE)
|
|
1549
|
+
if holder is None:
|
|
1550
|
+
created = types.ModuleType(SHARED_STATE_MODULE)
|
|
1551
|
+
setattr(created, "condition", threading.Condition())
|
|
1552
|
+
setattr(created, "slots", {})
|
|
1553
|
+
holder = sys.modules.setdefault(SHARED_STATE_MODULE, created)
|
|
1554
|
+
condition = getattr(holder, "condition")
|
|
1555
|
+
slots = getattr(holder, "slots")
|
|
1556
|
+
if not isinstance(condition, threading.Condition) or not isinstance(slots, dict):
|
|
1557
|
+
raise RuntimeError(f"{SHARED_STATE_MODULE} in sys.modules is not Pushary's Desktop answer state")
|
|
1558
|
+
return DesktopAnswers(condition, slots)
|
|
1559
|
+
` },
|
|
1560
|
+
{ path: "desktop_contract.py", contents: 'from __future__ import annotations\n\nfrom typing import Callable, List, Literal, Mapping, Optional, Tuple, TypedDict, Union\n\nfrom . import approval_ledger as ledger\nfrom . import cancellation\nfrom .desktop_answers import DesktopAnswers\n\nCONTRACT_VERSION = 1\nPLUGIN_ID = "pushary"\nAPPROVALS_CHANGED_EVENT = "approvals.changed"\nAPPROVALS_CHANGED_WIRE_EVENT = f"plugin.{PLUGIN_ID}.{APPROVALS_CHANGED_EVENT}"\nAPPROVALS_PATH = "/approvals"\nANSWER_PATH_SUFFIX = "/answer"\n\nFenceOutcome = Literal["answered_here", "already_closed", "unreachable"]\nAnswerOutcome = Union[FenceOutcome, ledger.RefusedClaim]\n\nANSWERED_HERE: FenceOutcome = "answered_here"\nALREADY_CLOSED: FenceOutcome = "already_closed"\nUNREACHABLE: FenceOutcome = "unreachable"\nFENCE_OUTCOMES: Tuple[FenceOutcome, ...] = (ANSWERED_HERE, ALREADY_CLOSED, UNREACHABLE)\nANSWER_OUTCOMES: Tuple[AnswerOutcome, ...] = (*FENCE_OUTCOMES, *ledger.REFUSED_CLAIMS)\n\nHOST_FAILURE_OUTCOMES: Tuple[str, ...] = (\n "transport_busy",\n "transport_error",\n "transport_interrupted",\n "transport_invalid",\n "transport_stale",\n "transport_timeout",\n)\n\nCancelQuestion = Callable[[str], object]\n\n\nclass OfferedChoiceWire(TypedDict):\n choice: ledger.ApprovalChoice\n label: str\n\n\nclass WaitingApprovalWire(TypedDict):\n correlationId: str\n subject: str\n choices: List[OfferedChoiceWire]\n openedAtMs: int\n deadlineAtMs: int\n answerableHere: bool\n routedTo: Optional[str]\n claim: Optional[ledger.ClaimState]\n\n\nclass RecentApprovalWire(TypedDict):\n correlationId: str\n subject: str\n openedAtMs: int\n settledAtMs: int\n outcome: ledger.RecentOutcome\n answerSource: Optional[str]\n hostOutcome: Optional[str]\n\n\nclass SetupWire(TypedDict):\n apiKey: bool\n transport: bool\n pluginVersion: Optional[str]\n\n\nclass ApprovalsResponse(TypedDict):\n version: int\n waiting: List[WaitingApprovalWire]\n recent: List[RecentApprovalWire]\n setup: SetupWire\n\n\nclass AnswerResponse(TypedDict):\n version: int\n outcome: AnswerOutcome\n\n\nclass ContractFacts(TypedDict):\n version: int\n approvalsChangedWireEvent: str\n approvalsPath: str\n answerPathSuffix: str\n correlationIdPattern: str\n tokenPattern: str\n approvalChoices: List[str]\n claimStates: List[str]\n recentOutcomes: List[str]\n answerOutcomes: List[str]\n hostFailureOutcomes: List[str]\n\n\ndef _offered_wire(offered: ledger.OfferedChoice) -> OfferedChoiceWire:\n return {"choice": offered.choice, "label": offered.label}\n\n\ndef _waiting_wire(item: ledger.WaitingApproval) -> WaitingApprovalWire:\n return {\n "correlationId": item.correlation_id,\n "subject": item.subject,\n "choices": [_offered_wire(offered) for offered in item.choices],\n "openedAtMs": item.opened_at_ms,\n "deadlineAtMs": item.deadline_at_ms,\n "answerableHere": item.answerable_here,\n "routedTo": item.routed_to,\n "claim": item.claim,\n }\n\n\ndef _recent_wire(item: ledger.RecentApproval) -> RecentApprovalWire:\n return {\n "correlationId": item.correlation_id,\n "subject": item.subject,\n "openedAtMs": item.opened_at_ms,\n "settledAtMs": item.settled_at_ms,\n "outcome": item.outcome,\n "answerSource": item.answer_source,\n "hostOutcome": item.host_outcome,\n }\n\n\ndef setup_wire(*, api_key_saved: bool, transport_selected: bool, plugin_version: Optional[str]) -> SetupWire:\n return {"apiKey": api_key_saved, "transport": transport_selected, "pluginVersion": plugin_version}\n\n\ndef view_with_answers_here(view: ledger.ApprovalsView, here: Tuple[ledger.WaitingApproval, ...]) -> ledger.ApprovalsView:\n answered_here = {item.correlation_id for item in here}\n elsewhere = tuple(item for item in view.waiting if item.correlation_id not in answered_here)\n waiting = tuple(sorted((*here, *elsewhere), key=lambda item: item.opened_at_ms))\n return ledger.ApprovalsView(waiting=waiting, recent=view.recent)\n\n\ndef approvals_response(view: ledger.ApprovalsView, setup: SetupWire) -> ApprovalsResponse:\n return {\n "version": CONTRACT_VERSION,\n "waiting": [_waiting_wire(item) for item in view.waiting],\n "recent": [_recent_wire(item) for item in view.recent],\n "setup": setup,\n }\n\n\ndef answer_response(outcome: AnswerOutcome) -> AnswerResponse:\n return {"version": CONTRACT_VERSION, "outcome": outcome}\n\n\ndef parse_answer_request(body: object) -> Optional[ledger.ApprovalChoice]:\n if not isinstance(body, Mapping):\n return None\n fields: Mapping[object, object] = body\n if set(fields) != {"choice"}:\n return None\n return ledger.approval_choice(fields.get("choice"))\n\n\ndef _refused(result: ledger.ClaimResult) -> Optional[ledger.RefusedClaim]:\n for refused in ledger.REFUSED_CLAIMS:\n if result == refused:\n return refused\n return None\n\n\ndef _cancel_from_desktop(cancel_question: CancelQuestion, correlation_id: str) -> cancellation.Cancellation:\n try:\n return cancellation.classify_cancellation(cancel_question(correlation_id))\n except Exception:\n return cancellation.UNREACHABLE\n\n\ndef answer_from_desktop(\n answers: DesktopAnswers,\n correlation_id: str,\n choice: ledger.ApprovalChoice,\n *,\n cancel_question: CancelQuestion,\n now_ms: int,\n) -> AnswerResponse:\n refused = _refused(answers.claim(correlation_id, choice, now_ms=now_ms))\n if refused is not None:\n return answer_response(refused)\n withdrawal = _cancel_from_desktop(cancel_question, correlation_id)\n if withdrawal == cancellation.WITHDRAWN_BY_THIS_CALL and answers.decide_from_desktop(correlation_id, choice):\n return answer_response(ANSWERED_HERE)\n answers.lose_claim(correlation_id)\n return answer_response(UNREACHABLE if withdrawal == cancellation.UNREACHABLE else ALREADY_CLOSED)\n\n\ndef contract_facts() -> ContractFacts:\n return {\n "version": CONTRACT_VERSION,\n "approvalsChangedWireEvent": APPROVALS_CHANGED_WIRE_EVENT,\n "approvalsPath": APPROVALS_PATH,\n "answerPathSuffix": ANSWER_PATH_SUFFIX,\n "correlationIdPattern": ledger.CORRELATION_ID_PATTERN.pattern,\n "tokenPattern": ledger.TOKEN_PATTERN.pattern,\n "approvalChoices": list(ledger.APPROVAL_CHOICES),\n "claimStates": list(ledger.CLAIM_STATES),\n "recentOutcomes": list(ledger.RECENT_OUTCOMES),\n "answerOutcomes": list(ANSWER_OUTCOMES),\n "hostFailureOutcomes": list(HOST_FAILURE_OUTCOMES),\n }\n' },
|
|
1561
|
+
{ path: "desktop/plugin.js", contents: "// Generated from desktop-src/plugin.ts by scripts/build-desktop.mjs. Edit the source, then run it.\nimport { atom, Button, CopyButton, host, Popover, PopoverContent, PopoverTrigger, STATUSBAR_AREAS, StatusDot, useValue } from '@hermes/plugin-sdk';\nimport { createElement as h, useEffect, useState } from 'react';\nexport const PLUGIN_ID = 'pushary';\nexport const CONTRACT_VERSION = 1;\nexport const APPROVALS_CHANGED_WIRE_EVENT = 'plugin.pushary.approvals.changed';\nexport const APPROVALS_PATH = '/approvals';\nexport const ANSWER_PATH_SUFFIX = '/answer';\nexport const CORRELATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nexport const TOKEN_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;\nexport const APPROVAL_CHOICES = ['once', 'session', 'always', 'deny'];\nexport const CLAIM_STATES = ['claiming', 'won', 'lost'];\nexport const RECENT_OUTCOMES = ['once', 'session', 'always', 'deny', 'handed_to_hermes', 'ended'];\nexport const ANSWER_OUTCOMES = [\n 'answered_here', 'already_closed', 'unreachable',\n 'not_found', 'not_waiting', 'not_answerable_here', 'not_offered', 'already_claimed'\n];\nexport const HOST_FAILURE_OUTCOMES = [\n 'transport_busy', 'transport_error', 'transport_interrupted', 'transport_invalid', 'transport_stale', 'transport_timeout'\n];\nconst isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);\nconst isOneOf = (values) => (value) => typeof value === 'string' && values.includes(value);\nconst isApprovalChoice = isOneOf(APPROVAL_CHOICES);\nconst isClaimState = isOneOf(CLAIM_STATES);\nconst isRecentOutcome = isOneOf(RECENT_OUTCOMES);\nconst isAnswerOutcome = isOneOf(ANSWER_OUTCOMES);\nconst isHostFailureOutcome = isOneOf(HOST_FAILURE_OUTCOMES);\nconst isInstant = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;\nconst isCorrelationId = (value) => typeof value === 'string' && CORRELATION_ID_PATTERN.test(value);\nconst isTokenOrNull = (value) => value === null || (typeof value === 'string' && TOKEN_PATTERN.test(value));\nconst isContractVersion = (value) => value === CONTRACT_VERSION;\nconst parseList = (value, parse) => {\n if (!Array.isArray(value))\n return null;\n const parsed = [];\n for (const item of value) {\n const result = parse(item);\n if (result === null)\n return null;\n parsed.push(result);\n }\n return parsed;\n};\nconst parseOfferedChoice = (value) => {\n if (!isRecord(value) || !isApprovalChoice(value.choice) || typeof value.label !== 'string')\n return null;\n return { choice: value.choice, label: value.label };\n};\nconst parseWaitingApproval = (value) => {\n if (!isRecord(value))\n return null;\n const choices = parseList(value.choices, parseOfferedChoice);\n const claim = value.claim === null || isClaimState(value.claim) ? value.claim : undefined;\n const routedTo = value.routedTo === null || typeof value.routedTo === 'string' ? value.routedTo : undefined;\n if (!isCorrelationId(value.correlationId) || typeof value.subject !== 'string' || choices === null\n || !isInstant(value.openedAtMs) || !isInstant(value.deadlineAtMs) || claim === undefined\n || typeof value.answerableHere !== 'boolean' || routedTo === undefined)\n return null;\n return {\n correlationId: value.correlationId,\n subject: value.subject,\n choices,\n openedAtMs: value.openedAtMs,\n deadlineAtMs: value.deadlineAtMs,\n answerableHere: value.answerableHere,\n routedTo,\n claim\n };\n};\nconst parseRecentApproval = (value) => {\n if (!isRecord(value) || !isCorrelationId(value.correlationId) || typeof value.subject !== 'string'\n || !isInstant(value.openedAtMs) || !isInstant(value.settledAtMs) || !isRecentOutcome(value.outcome)\n || !isTokenOrNull(value.answerSource) || !isTokenOrNull(value.hostOutcome))\n return null;\n return {\n correlationId: value.correlationId,\n subject: value.subject,\n openedAtMs: value.openedAtMs,\n settledAtMs: value.settledAtMs,\n outcome: value.outcome,\n answerSource: value.answerSource,\n hostOutcome: value.hostOutcome\n };\n};\nconst parseSetup = (value) => {\n if (!isRecord(value) || typeof value.apiKey !== 'boolean' || typeof value.transport !== 'boolean')\n return null;\n const pluginVersion = value.pluginVersion;\n if (pluginVersion !== null && typeof pluginVersion !== 'string')\n return null;\n return { apiKey: value.apiKey, transport: value.transport, pluginVersion };\n};\nexport const parseApprovalsResponse = (value) => {\n if (!isRecord(value) || !isContractVersion(value.version))\n return null;\n const waiting = parseList(value.waiting, parseWaitingApproval);\n const recent = parseList(value.recent, parseRecentApproval);\n const setup = parseSetup(value.setup);\n if (waiting === null || recent === null || setup === null)\n return null;\n return { version: value.version, waiting, recent, setup };\n};\nexport const parseAnswerResponse = (value) => {\n if (!isRecord(value) || !isContractVersion(value.version) || !isAnswerOutcome(value.outcome))\n return null;\n return { version: value.version, outcome: value.outcome };\n};\nexport const answerPath = (correlationId) => isCorrelationId(correlationId) ? `${APPROVALS_PATH}/${correlationId}${ANSWER_PATH_SUFFIX}` : null;\nconst LIVE_REFRESH_MS = 2000;\nconst IDLE_REFRESH_MS = 15000;\nconst CLOCK_TICK_MS = 1000;\nconst GATEWAY_OPEN = 'open';\nconst SETTINGS_CHANGED_EVENT = `plugin.${PLUGIN_ID}.settings.changed`;\nconst TRANSPORT_COMMAND = 'hermes config set security.approval.transport pushary';\nconst SIGN_UP_URL = 'https://pushary.com/sign-up?from=hermes';\nconst UNKNOWN_SURFACE = 'Pushary';\nconst NOT_ANSWERED = 'Not answered';\nconst UNNAMED_SUBJECT = 'A command Hermes flagged';\nconst SENDING_ANSWER = 'Sending your answer\u2026';\nconst ANSWER_SURFACES = {\n mobile_app: 'phone',\n mobile_background: 'phone',\n mac_app: 'Mac',\n slack: 'Slack',\n dashboard: 'web',\n decide_page: 'web',\n live_page: 'web',\n answer_link: 'web',\n inbox: 'web',\n sandbox: 'sandbox',\n hermes_desktop: 'Hermes Desktop'\n};\nconst HOST_FAILURE_TEXT = {\n transport_busy: 'Hermes was busy',\n transport_error: 'Failed in Hermes',\n transport_interrupted: 'Turn interrupted',\n transport_invalid: 'Refused by Hermes',\n transport_stale: 'Refused by Hermes',\n transport_timeout: 'Timed out in Hermes'\n};\nconst CHOICE_RESULTS = {\n once: 'Allowed once',\n session: 'Allowed for this session',\n always: 'Always allowed',\n deny: 'Denied'\n};\nconst CHOICE_BUTTON_VARIANTS = {\n once: 'default',\n session: 'secondary',\n always: 'secondary',\n deny: 'outline'\n};\nconst ANSWER_NOTICES = {\n answered_here: { kind: 'success', message: 'Answered from Hermes Desktop.' },\n already_closed: { kind: 'info', message: 'This approval had already closed, so your answer was not used.' },\n unreachable: { kind: 'error', message: 'Pushary did not confirm your answer, so it was not used.' },\n not_found: { kind: 'info', message: 'This approval has already ended.' },\n not_waiting: { kind: 'info', message: 'This approval has already ended.' },\n not_answerable_here: { kind: 'info', message: 'A Team route sends this approval to someone else to answer.' },\n not_offered: { kind: 'error', message: 'Hermes did not offer that choice.' },\n already_claimed: { kind: 'info', message: 'This approval is already being answered.' }\n};\nconst ANSWER_FAILED = {\n kind: 'error',\n message: 'Hermes Desktop could not send that answer. Answer on your phone.'\n};\nexport const waitedFor = (ms) => {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n};\nexport const ago = (ms) => {\n const minutes = Math.floor(Math.max(0, ms) / 60000);\n if (minutes < 1)\n return 'just now';\n if (minutes < 60)\n return `${minutes} min ago`;\n const hours = Math.floor(minutes / 60);\n if (hours < 24)\n return `${hours} h ago`;\n return `${Math.floor(hours / 24)} d ago`;\n};\nconst surfaceOf = (answerSource) => ANSWER_SURFACES[answerSource] ?? UNKNOWN_SURFACE;\nconst choiceOutcomeText = (choice, answerSource) => {\n if (answerSource === null)\n return choice === 'deny' ? NOT_ANSWERED : CHOICE_RESULTS[choice];\n return `${CHOICE_RESULTS[choice]} \xB7 ${surfaceOf(answerSource)}`;\n};\nexport const outcomeText = (item) => {\n if (item.outcome === 'handed_to_hermes')\n return 'Handed to Hermes';\n if (isHostFailureOutcome(item.hostOutcome))\n return HOST_FAILURE_TEXT[item.hostOutcome];\n if (item.outcome === 'ended')\n return 'Ended';\n return choiceOutcomeText(item.outcome, item.answerSource);\n};\nexport const waitingLabel = (count) => count === 1 ? '1 approval waiting' : `${count} approvals waiting`;\nexport const waitingLine = (item, now) => {\n const waited = waitedFor(now - item.openedAtMs);\n if (!item.answerableHere) {\n return item.routedTo === null ? `Answer it on your phone \xB7 ${waited}` : `Routed to ${item.routedTo} \xB7 ${waited}`;\n }\n switch (item.claim) {\n case 'claiming':\n return SENDING_ANSWER;\n case 'won':\n return 'Answered here. Handing it to Hermes\u2026';\n case 'lost':\n return `Answer it on your phone \xB7 ${waited}`;\n case null:\n return `Waiting on your phone \xB7 ${waited}`;\n }\n};\nexport const approvalStatusLine = (item, now, answering) => answering === item.correlationId ? SENDING_ANSWER : waitingLine(item, now);\nconst subjectOf = (subject) => subject || UNNAMED_SUBJECT;\nconst readyChipSummary = (response) => {\n if (response.waiting.length > 0) {\n const waiting = waitingLabel(response.waiting.length);\n return { tone: 'warn', text: `Pushary \xB7 ${waiting}`, label: `Pushary: ${waiting}` };\n }\n if (!response.setup.apiKey)\n return { tone: 'warn', text: 'Pushary \xB7 set up', label: 'Pushary needs your API key' };\n if (!response.setup.transport) {\n return { tone: 'warn', text: 'Pushary \xB7 not routing', label: 'Approvals are not routed to Pushary' };\n }\n return { tone: 'good', text: 'Pushary', label: 'Pushary carries approvals to your phone' };\n};\nexport const chipSummary = (state) => {\n switch (state.kind) {\n case 'loading':\n return { tone: 'muted', text: 'Pushary', label: 'Pushary is loading' };\n case 'offline':\n return { tone: 'muted', text: 'Pushary', label: 'The Pushary backend is not running' };\n case 'incompatible':\n return { tone: 'bad', text: 'Pushary \xB7 update', label: 'This Pushary Desktop half does not match its backend' };\n case 'ready':\n return readyChipSummary(state.response);\n }\n};\nexport const answerNotice = (value) => {\n const response = parseAnswerResponse(value);\n return response === null ? ANSWER_FAILED : ANSWER_NOTICES[response.outcome];\n};\nexport const panelStateFrom = (value) => {\n const response = parseApprovalsResponse(value);\n return response === null ? { kind: 'incompatible' } : { kind: 'ready', response };\n};\nconst waitingCount = (state) => (state.kind === 'ready' ? state.response.waiting.length : 0);\nexport const createApprovalsStore = (ctx) => {\n const state = atom({ kind: 'loading' });\n const open = atom(false);\n const answering = atom(null);\n let inFlight = null;\n let refreshAgain = false;\n let loadedAt = Number.NEGATIVE_INFINITY;\n const load = async () => {\n try {\n state.set(panelStateFrom(await ctx.rest(APPROVALS_PATH)));\n }\n catch {\n state.set({ kind: 'offline' });\n }\n finally {\n loadedAt = Date.now();\n }\n };\n const refresh = () => {\n if (inFlight !== null) {\n refreshAgain = true;\n return inFlight;\n }\n inFlight = load().finally(() => {\n inFlight = null;\n if (refreshAgain) {\n refreshAgain = false;\n void refresh();\n }\n });\n return inFlight;\n };\n const answer = async (item, choice) => {\n const path = answerPath(item.correlationId);\n if (answering.get() !== null || path === null)\n return;\n answering.set(item.correlationId);\n try {\n host.notify(answerNotice(await ctx.rest(path, { method: 'POST', body: { choice } })));\n }\n catch {\n host.notify(ANSWER_FAILED);\n }\n finally {\n answering.set(null);\n void refresh();\n }\n };\n const refreshIsDue = (now) => open.get() || waitingCount(state.get()) > 0 || now - loadedAt >= IDLE_REFRESH_MS;\n return { state, open, answering, refresh, answer, refreshIsDue };\n};\nconst STYLES = {\n chip: { display: 'inline-flex', alignItems: 'center', gap: '6px' },\n popover: { width: 'min(340px, calc(100vw - 24px))', padding: 0 },\n panel: {\n padding: '12px', display: 'flex', flexDirection: 'column', gap: '12px', overflowY: 'auto', overscrollBehavior: 'contain',\n maxHeight: 'min(560px, calc(var(--radix-popover-content-available-height, 100vh) - 16px))'\n },\n heading: { fontSize: '12px', fontWeight: 600, color: 'var(--ui-text-primary)' },\n muted: { fontSize: '11px', lineHeight: '16px', color: 'var(--ui-text-tertiary)' },\n section: { display: 'flex', flexDirection: 'column', gap: '8px' },\n card: {\n display: 'flex', flexDirection: 'column', gap: '8px', padding: '8px',\n borderRadius: '4px', boxShadow: 'inset 0 0 0 1px var(--ui-stroke-secondary)'\n },\n subject: {\n fontFamily: 'var(--font-mono, monospace)', fontSize: '11px', lineHeight: '16px',\n color: 'var(--ui-text-primary)', wordBreak: 'break-word', maxHeight: '160px', overflowY: 'auto'\n },\n choices: { display: 'flex', flexWrap: 'wrap', gap: '6px' },\n command: {\n fontFamily: 'var(--font-mono, monospace)', fontSize: '11px', padding: '6px 8px', borderRadius: '4px',\n background: 'var(--ui-bg-quaternary)', color: 'var(--ui-text-secondary)', userSelect: 'text', wordBreak: 'break-all'\n },\n row: { display: 'flex', justifyContent: 'space-between', gap: '8px', fontSize: '11px', lineHeight: '16px' },\n rowSubject: {\n overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',\n fontFamily: 'var(--font-mono, monospace)', color: 'var(--ui-text-secondary)'\n },\n rowOutcome: { flexShrink: 0, color: 'var(--ui-text-tertiary)' }\n};\nconst useNow = (ticking) => {\n const [now, setNow] = useState(Date.now());\n useEffect(() => {\n if (!ticking)\n return undefined;\n const timer = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);\n return () => clearInterval(timer);\n }, [ticking]);\n return now;\n};\nconst Muted = ({ text }) => h('div', { style: STYLES.muted }, text);\nconst SetupNotice = ({ setup, ctx }) => {\n if (!setup.apiKey) {\n return h('div', { style: STYLES.section }, h(Muted, { text: 'Add your Pushary API key under Settings, Plugins, Pushary.' }), h('div', null, h(Button, {\n variant: 'secondary', size: 'xs', onClick: () => void ctx.os.openExternal(SIGN_UP_URL), children: 'Get a key'\n })));\n }\n if (!setup.transport) {\n return h('div', { style: STYLES.section }, h(Muted, { text: 'Hermes is not sending approvals to Pushary yet. Run this once:' }), h('div', { style: STYLES.command }, TRANSPORT_COMMAND), h('div', null, h(CopyButton, { text: TRANSPORT_COMMAND, label: 'Copy', showLabel: true, buttonSize: 'xs' })));\n }\n return null;\n};\nconst ChoiceButtons = ({ item, store }) => {\n const answering = useValue(store.answering);\n const disabled = answering !== null || item.claim !== null;\n return h('div', { style: STYLES.choices }, ...item.choices.map(offered => h(Button, {\n key: offered.choice,\n variant: CHOICE_BUTTON_VARIANTS[offered.choice],\n size: 'xs',\n disabled,\n onClick: () => void store.answer(item, offered.choice),\n children: offered.label\n })));\n};\nconst ApprovalCard = ({ item, store, now }) => {\n const answering = useValue(store.answering);\n return h('div', { style: STYLES.card }, h('div', { style: STYLES.subject }, subjectOf(item.subject)), h(Muted, { text: approvalStatusLine(item, now, answering) }), item.answerableHere ? h(ChoiceButtons, { item, store }) : null);\n};\nconst RecentRow = ({ item, now }) => h('div', { style: STYLES.row }, h('span', { style: STYLES.rowSubject, title: subjectOf(item.subject) }, subjectOf(item.subject)), h('span', { style: STYLES.rowOutcome }, `${outcomeText(item)} \xB7 ${ago(now - item.settledAtMs)}`));\nconst WaitingList = ({ response, store, now }) => {\n if (response.waiting.length === 0) {\n return h(Muted, { text: 'No approvals waiting. Answer them here or on your phone, whichever is closer.' });\n }\n return h('div', { style: STYLES.section }, ...response.waiting.map(item => h(ApprovalCard, { key: item.correlationId, item, store, now })));\n};\nconst RecentList = ({ recent, now }) => {\n if (recent.length === 0)\n return null;\n return h('div', { style: STYLES.section }, h(Muted, { text: 'Recent' }), ...recent.map(item => h(RecentRow, { key: item.correlationId, item, now })));\n};\nconst ReadyPanel = ({ response, store, ctx }) => {\n const now = useNow(response.waiting.length > 0);\n const heading = response.waiting.length ? waitingLabel(response.waiting.length) : 'Pushary';\n return h('div', { style: STYLES.panel }, h('div', { style: STYLES.heading }, heading), h(SetupNotice, { setup: response.setup, ctx }), h(WaitingList, { response, store, now }), h(RecentList, { recent: response.recent, now }));\n};\nconst PANEL_MESSAGES = {\n loading: 'Loading\u2026',\n offline: 'The Pushary backend is not running. Enable Pushary under Capabilities, Plugins, then restart Hermes.',\n incompatible: 'This copy of the Pushary Desktop half does not match its backend. Rescan plugins under Capabilities.'\n};\nexport const PusharyPanel = ({ store, ctx }) => {\n const state = useValue(store.state);\n if (state.kind !== 'ready')\n return h('div', { style: STYLES.panel }, h(Muted, { text: PANEL_MESSAGES[state.kind] }));\n return h(ReadyPanel, { response: state.response, store, ctx });\n};\nexport const PusharyChip = ({ store, ctx }) => {\n const summary = chipSummary(useValue(store.state));\n const open = useValue(store.open);\n const onOpenChange = (value) => {\n store.open.set(value);\n if (value)\n void store.refresh();\n };\n return h(Popover, {\n open,\n onOpenChange,\n children: [\n h(PopoverTrigger, { key: 'trigger', asChild: true, children: h(Button, {\n variant: 'ghost', size: 'micro', 'aria-label': summary.label, title: summary.label,\n children: h('span', { style: STYLES.chip }, h(StatusDot, { tone: summary.tone }), h('span', null, summary.text))\n }) }),\n h(PopoverContent, { key: 'content', side: 'top', align: 'end', 'aria-label': 'Pushary approvals',\n style: STYLES.popover, children: h(PusharyPanel, { store, ctx }) })\n ]\n });\n};\nconst registerPushary = (ctx) => {\n const store = createApprovalsStore(ctx);\n ctx.register({ id: 'approvals', area: STATUSBAR_AREAS.right, order: 120, render: () => h(PusharyChip, { store, ctx }) });\n ctx.onEvent(APPROVALS_CHANGED_WIRE_EVENT, () => void store.refresh());\n ctx.onEvent(SETTINGS_CHANGED_EVENT, () => void store.refresh());\n ctx.onDispose(host.state.gateway.listen(value => {\n if (value === GATEWAY_OPEN)\n void store.refresh();\n }));\n ctx.setInterval(() => {\n if (store.refreshIsDue(Date.now()))\n void store.refresh();\n }, LIVE_REFRESH_MS);\n void store.refresh();\n};\nconst plugin = {\n id: PLUGIN_ID,\n name: 'Pushary',\n description: 'Approvals Pushary is carrying to your phone, answerable here too.',\n register: registerPushary\n};\nexport default plugin;\n" },
|
|
941
1562
|
{ path: "history.py", contents: `from __future__ import annotations
|
|
942
1563
|
|
|
943
1564
|
import hashlib
|
|
@@ -1415,7 +2036,7 @@ def load_record(approval_transport_registered, pid, loaded_at):
|
|
|
1415
2036
|
}
|
|
1416
2037
|
|
|
1417
2038
|
|
|
1418
|
-
def
|
|
2039
|
+
def process_is_running(pid):
|
|
1419
2040
|
if os.name == "nt":
|
|
1420
2041
|
return True
|
|
1421
2042
|
try:
|
|
@@ -1428,7 +2049,7 @@ def _process_is_running(pid):
|
|
|
1428
2049
|
|
|
1429
2050
|
|
|
1430
2051
|
def _record_belongs_to_running_process(record):
|
|
1431
|
-
return record.stem.isdigit() and
|
|
2052
|
+
return record.stem.isdigit() and process_is_running(int(record.stem))
|
|
1432
2053
|
|
|
1433
2054
|
|
|
1434
2055
|
def _prune_expired_load_records(directory, keep, now):
|
|
@@ -1463,7 +2084,7 @@ def record_plugin_load(approval_transport_registered, home=None, pid=None, now=N
|
|
|
1463
2084
|
logger.debug("[pushary] could not record the plugin load: %s", exc)
|
|
1464
2085
|
return None
|
|
1465
2086
|
` },
|
|
1466
|
-
{ path: "plugin.yaml", contents:
|
|
2087
|
+
{ path: "plugin.yaml", contents: 'name: pushary\nversion: 1.0.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\nconfig_schema:\n api_key:\n type: secret\n env: PUSHARY_API_KEY\n label: Pushary API key\n description: "Get one at pushary.com/sign-up?from=hermes"\n required: 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\n - post_approval_response\nprovides_approval_transports:\n - pushary\n' },
|
|
1467
2088
|
{ path: "schemas.py", contents: `PUSHARY_REMIND = {
|
|
1468
2089
|
"name": "pushary_remind",
|
|
1469
2090
|
"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.",
|