pushary 1.9.101 → 1.9.103

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.103
4
+
5
+ - The Hermes plugin Pushary installs is now 1.0.1. When it hands a Hermes session to Pushary's history helper, the helper now gets only what it reads: the home folder, the search path, the temporary folder and locale, the Pushary settings it uses, and the Pushary key Hermes holds. Hermes' other provider keys no longer reach it. Checking whether history is allowed sends the key on standard input only.
6
+
7
+ ## 1.9.102
8
+
9
+ - The Hermes plugin Pushary installs is now 1.0.0. In Hermes Desktop, a Pushary chip in the status bar shows the approvals waiting on your phone, and you can answer them there too. Only one answer counts: a Hermes Desktop answer is used only when Pushary confirms that answer is what withdrew the question, and otherwise the panel says the approval had already closed. The chip also lists how recent approvals ended and where they were answered. Enable Pushary under Capabilities, Plugins in Hermes Desktop to see it.
10
+ - On a Team workspace, a Hermes approval that an approval route sends to someone else, with "Only they can answer" on, is no longer handed to Hermes' own prompt. The plugin waits for the route's approvers until Hermes' timeout and denies if none of them answers.
11
+ - When Hermes has already given up on an approval, because it timed out or the turn was interrupted, the plugin withdraws the question from your phone at its next check instead of leaving it open.
12
+
3
13
  ## 1.9.101
4
14
 
5
15
  - When npm cannot write to its global folder (an `EACCES` from a system Node install on Linux, for example), setup now reports that same reason for every agent it connects, and tells each one to fix the npm prefix. Before, only the first agent got that reason. The others said an earlier installation "did not finish safely" and to retry, which could not work.
@@ -53,7 +53,7 @@ import {
53
53
  defaultHermesHome,
54
54
  hermesReceiptPaths,
55
55
  removeHermesWiring
56
- } from "../chunk-VSE4OIBT.js";
56
+ } from "../chunk-COBOR3ST.js";
57
57
  import "../chunk-QCGNGEGE.js";
58
58
  import {
59
59
  planClineRemoval,
@@ -814,7 +814,7 @@ var decidePreToolUse = async (input) => {
814
814
  };
815
815
  var repairCodexHoldsQuietly = async () => {
816
816
  try {
817
- const { repairCodexHolds } = await import("../reapply-GTEJJ6HZ.js");
817
+ const { repairCodexHolds } = await import("../reapply-LDCMRN6Q.js");
818
818
  repairCodexHolds();
819
819
  } catch {
820
820
  return;
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  inspectConfiguredManagedProviders
4
- } from "../chunk-IUBDGISO.js";
4
+ } from "../chunk-RMFL62HR.js";
5
5
  import {
6
6
  HEARTBEAT_INTERVAL_MS,
7
7
  sendHeartbeat
@@ -14,7 +14,7 @@ import {
14
14
  HERMES_INSTALL_MARKER_FILE,
15
15
  defaultHermesHome,
16
16
  parseHermesInstallMarker
17
- } from "../chunk-VSE4OIBT.js";
17
+ } from "../chunk-COBOR3ST.js";
18
18
  import {
19
19
  writeHuman
20
20
  } from "../chunk-QCGNGEGE.js";
@@ -75,7 +75,7 @@ import {
75
75
  routesHermesQuestions,
76
76
  runsAnOlderPlugin,
77
77
  shippedHermesPlugin
78
- } from "../chunk-VSE4OIBT.js";
78
+ } from "../chunk-COBOR3ST.js";
79
79
  import "../chunk-QCGNGEGE.js";
80
80
  import {
81
81
  FX_PROFILE_REFUSALS,
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  reapplyClaudeForVersion
4
- } from "../chunk-IUBDGISO.js";
4
+ } from "../chunk-RMFL62HR.js";
5
5
  import "../chunk-RVSRPDRF.js";
6
- import "../chunk-VSE4OIBT.js";
6
+ import "../chunk-COBOR3ST.js";
7
7
  import "../chunk-SXAZPWL7.js";
8
8
  import "../chunk-JECH4NJL.js";
9
9
  import "../chunk-AZSOH2SF.js";
@@ -136,7 +136,7 @@ import {
136
136
  routesHermesQuestions,
137
137
  runsAnOlderPlugin,
138
138
  shippedHermesPlugin
139
- } from "../chunk-VSE4OIBT.js";
139
+ } from "../chunk-COBOR3ST.js";
140
140
  import {
141
141
  setHumanStream
142
142
  } from "../chunk-QCGNGEGE.js";
@@ -28,7 +28,7 @@ import {
28
28
  import {
29
29
  ensureCodexTrustAfterReapply,
30
30
  reapplyAllAgents
31
- } from "../chunk-IUBDGISO.js";
31
+ } from "../chunk-RMFL62HR.js";
32
32
  import {
33
33
  HERMES_RESTART_COMMAND,
34
34
  describeCodexTrustConfirmation
@@ -42,7 +42,7 @@ import {
42
42
  readMacAppAgentKey
43
43
  } from "../chunk-JKOAAIJD.js";
44
44
  import "../chunk-WYJ6QM7K.js";
45
- import "../chunk-VSE4OIBT.js";
45
+ import "../chunk-COBOR3ST.js";
46
46
  import {
47
47
  FX_RELOAD_AFTER_BRIDGE,
48
48
  fxHttpEntryKeptNote,
@@ -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.1",\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
@@ -969,6 +1590,11 @@ SNAPSHOT_PRUNE_INTERVAL_SECONDS = 60 * 60
969
1590
  SOURCE_UNAVAILABLE = "source_unavailable"
970
1591
  HISTORY_NOT_ALLOWED_WARNING = ("[pushary] Hermes session history is not saved on this computer; it needs "
971
1592
  "\`npx pushary@latest setup\` with the key Hermes uses, or Session history turned on in the Pushary Mac app")
1593
+ HELPER_ENVIRONMENT_NAMES = frozenset({
1594
+ "HOME", "PATH", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE",
1595
+ "SYSTEMROOT", "PATHEXT", "COMSPEC", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
1596
+ "PUSHARY_BASE_URL", "PUSHARY_API_URL", "PUSHARY_CONFIG_FILE", "PUSHARY_TRANSCRIPTS", "PUSHARY_TRANSCRIPT_REGISTRY_DIR",
1597
+ })
972
1598
 
973
1599
 
974
1600
  class _Authorization(NamedTuple):
@@ -1096,6 +1722,14 @@ def write_snapshot(path: Path, session_id: str, messages: list[dict[str, object]
1096
1722
  Path(temporary).unlink(missing_ok=True)
1097
1723
 
1098
1724
 
1725
+ def _helper_environment() -> dict[str, str]:
1726
+ return {name: value for name, value in os.environ.items() if name in HELPER_ENVIRONMENT_NAMES}
1727
+
1728
+
1729
+ def _registration_environment(key: str) -> dict[str, str]:
1730
+ return {**_helper_environment(), "PUSHARY_API_KEY": key, "PUSHARY_BASE_URL": api.BASE_URL}
1731
+
1732
+
1099
1733
  def _registration_command(key: str) -> list[str] | None:
1100
1734
  root = Path.home() / ".pushary"
1101
1735
  try:
@@ -1110,7 +1744,7 @@ def _registration_command(key: str) -> list[str] | None:
1110
1744
  command = [helper, "hermes"]
1111
1745
  result = subprocess.run([helper, "authorize-export"],
1112
1746
  input=json.dumps({"apiKey": key, "baseUrl": api.BASE_URL}), text=True,
1113
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5, check=False)
1747
+ env=_helper_environment(), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5, check=False)
1114
1748
  if result.returncode != 0:
1115
1749
  return None
1116
1750
  authorization = json.loads(result.stdout)
@@ -1264,8 +1898,7 @@ def _export_loop(database_path: Path, session_id: str, key: str, command: list[s
1264
1898
  if ending or time.monotonic() >= next_registration:
1265
1899
  payload = {"session_id": session_id, "cwd": str(database_path.parent), "transcript_path": str(path),
1266
1900
  "hook_event_name": "SessionEnd" if ending else "SessionStart"}
1267
- environment = {**os.environ, "PUSHARY_API_KEY": key, "PUSHARY_BASE_URL": api.BASE_URL}
1268
- result = subprocess.run(command, input=json.dumps(payload), text=True, env=environment,
1901
+ result = subprocess.run(command, input=json.dumps(payload), text=True, env=_registration_environment(key),
1269
1902
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, check=False)
1270
1903
  if result.returncode != 0:
1271
1904
  logger.warning("[pushary] Hermes history reader could not register")
@@ -1415,7 +2048,7 @@ def load_record(approval_transport_registered, pid, loaded_at):
1415
2048
  }
1416
2049
 
1417
2050
 
1418
- def _process_is_running(pid):
2051
+ def process_is_running(pid):
1419
2052
  if os.name == "nt":
1420
2053
  return True
1421
2054
  try:
@@ -1428,7 +2061,7 @@ def _process_is_running(pid):
1428
2061
 
1429
2062
 
1430
2063
  def _record_belongs_to_running_process(record):
1431
- return record.stem.isdigit() and _process_is_running(int(record.stem))
2064
+ return record.stem.isdigit() and process_is_running(int(record.stem))
1432
2065
 
1433
2066
 
1434
2067
  def _prune_expired_load_records(directory, keep, now):
@@ -1463,7 +2096,7 @@ def record_plugin_load(approval_transport_registered, home=None, pid=None, now=N
1463
2096
  logger.debug("[pushary] could not record the plugin load: %s", exc)
1464
2097
  return None
1465
2098
  ` },
1466
- { path: "plugin.yaml", contents: "name: pushary\nversion: 0.9.2\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" },
2099
+ { path: "plugin.yaml", contents: 'name: pushary\nversion: 1.0.1\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
2100
  { path: "schemas.py", contents: `PUSHARY_REMIND = {
1468
2101
  "name": "pushary_remind",
1469
2102
  "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.",
@@ -7,7 +7,7 @@ import {
7
7
  installHermesPlugin,
8
8
  refreshHermesPluginFiles,
9
9
  shippedHermesPlugin
10
- } from "./chunk-VSE4OIBT.js";
10
+ } from "./chunk-COBOR3ST.js";
11
11
  import {
12
12
  fxIgnoresInstructionsFile,
13
13
  fxInstructionsBody,
@@ -12,9 +12,9 @@ import {
12
12
  reapplyOpenCodePlugin,
13
13
  reapplyVsCodePlugin,
14
14
  repairCodexHolds
15
- } from "./chunk-IUBDGISO.js";
15
+ } from "./chunk-RMFL62HR.js";
16
16
  import "./chunk-RVSRPDRF.js";
17
- import "./chunk-VSE4OIBT.js";
17
+ import "./chunk-COBOR3ST.js";
18
18
  import "./chunk-SXAZPWL7.js";
19
19
  import "./chunk-JECH4NJL.js";
20
20
  import "./chunk-AZSOH2SF.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pushary",
3
- "version": "1.9.101",
3
+ "version": "1.9.103",
4
4
  "description": "Human-in-the-loop (HITL) for AI agents: ask a person for approvals or answers by push notification. One command connects Claude Code, Codex, Cursor, Gemini CLI and more.",
5
5
  "keywords": [
6
6
  "pushary",