clawmes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. clawmes/__init__.py +142 -0
  2. clawmes/_version.py +10 -0
  3. clawmes/bridges/__init__.py +24 -0
  4. clawmes/bridges/installer.py +126 -0
  5. clawmes/bridges/process.py +263 -0
  6. clawmes/bridges/sa_client.py +96 -0
  7. clawmes/bridges/sources/wc/package-lock.json +1130 -0
  8. clawmes/bridges/sources/wc/package.json +22 -0
  9. clawmes/bridges/sources/wc/src/index.ts +137 -0
  10. clawmes/bridges/sources/wc/src/methods.ts +247 -0
  11. clawmes/bridges/sources/wc/src/wc-client.ts +63 -0
  12. clawmes/bridges/sources/wc/tsconfig.json +18 -0
  13. clawmes/bridges/wc_client.py +77 -0
  14. clawmes/cli/__init__.py +45 -0
  15. clawmes/cli/_argparse.py +159 -0
  16. clawmes/cli/doctor.py +151 -0
  17. clawmes/cli/init.py +338 -0
  18. clawmes/cli/version.py +21 -0
  19. clawmes/commands/__init__.py +40 -0
  20. clawmes/commands/doctor.py +315 -0
  21. clawmes/commands/help.py +96 -0
  22. clawmes/commands/plans.py +67 -0
  23. clawmes/commands/policy.py +121 -0
  24. clawmes/commands/tx.py +160 -0
  25. clawmes/commands/wallet.py +193 -0
  26. clawmes/data/SOUL.md +61 -0
  27. clawmes/data/__init__.py +2 -0
  28. clawmes/data/personas/chill.md +9 -0
  29. clawmes/data/personas/degen.md +9 -0
  30. clawmes/data/personas/mentor.md +9 -0
  31. clawmes/data/personas/professional.md +9 -0
  32. clawmes/data/personas/technical.md +9 -0
  33. clawmes/hooks/__init__.py +39 -0
  34. clawmes/hooks/after_tool_call.py +132 -0
  35. clawmes/hooks/on_session.py +39 -0
  36. clawmes/hooks/pre_gateway_dispatch.py +51 -0
  37. clawmes/hooks/pre_tool_call.py +32 -0
  38. clawmes/hooks/prompt_builder.py +101 -0
  39. clawmes/hooks/subagent_stop.py +39 -0
  40. clawmes/hooks/transform_terminal_output.py +33 -0
  41. clawmes/hooks/transform_tool_result.py +42 -0
  42. clawmes/ledger/__init__.py +14 -0
  43. clawmes/ledger/tx_ledger.py +128 -0
  44. clawmes/lib/__init__.py +6 -0
  45. clawmes/lib/abi.py +137 -0
  46. clawmes/lib/addr.py +72 -0
  47. clawmes/lib/chains.py +140 -0
  48. clawmes/lib/decimals.py +74 -0
  49. clawmes/lib/ens.py +164 -0
  50. clawmes/lib/http.py +196 -0
  51. clawmes/lib/logger.py +72 -0
  52. clawmes/lib/params.py +104 -0
  53. clawmes/lib/paths.py +91 -0
  54. clawmes/lib/time.py +95 -0
  55. clawmes/lib/tool_result.py +69 -0
  56. clawmes/onboarding/__init__.py +22 -0
  57. clawmes/onboarding/flow.py +35 -0
  58. clawmes/onboarding/personas.py +60 -0
  59. clawmes/persona.py +74 -0
  60. clawmes/plans/__init__.py +32 -0
  61. clawmes/plans/compiler.py +28 -0
  62. clawmes/plans/executor.py +32 -0
  63. clawmes/plans/ir.py +78 -0
  64. clawmes/plans/scheduler.py +131 -0
  65. clawmes/plans/triggers/__init__.py +8 -0
  66. clawmes/plans/triggers/price_trigger.py +44 -0
  67. clawmes/plans/triggers/time_trigger.py +33 -0
  68. clawmes/plans/validator.py +103 -0
  69. clawmes/plugin.yaml +92 -0
  70. clawmes/policy/__init__.py +36 -0
  71. clawmes/policy/confirm_store.py +82 -0
  72. clawmes/policy/evaluator.py +93 -0
  73. clawmes/policy/parser.py +269 -0
  74. clawmes/policy/storage.py +109 -0
  75. clawmes/policy/types.py +117 -0
  76. clawmes/policy/usage_counter.py +72 -0
  77. clawmes/services/__init__.py +111 -0
  78. clawmes/services/_base.py +57 -0
  79. clawmes/services/aave.py +171 -0
  80. clawmes/services/bankr_service.py +216 -0
  81. clawmes/services/coingecko.py +170 -0
  82. clawmes/services/credential_redactor.py +280 -0
  83. clawmes/services/explorer.py +226 -0
  84. clawmes/services/governance.py +125 -0
  85. clawmes/services/lifi.py +185 -0
  86. clawmes/services/mode_service.py +75 -0
  87. clawmes/services/persona_service.py +85 -0
  88. clawmes/services/price.py +114 -0
  89. clawmes/services/registry.py +72 -0
  90. clawmes/services/rpc.py +327 -0
  91. clawmes/services/safe.py +140 -0
  92. clawmes/services/staking.py +102 -0
  93. clawmes/services/token_decimals.py +216 -0
  94. clawmes/services/wallet.py +210 -0
  95. clawmes/services/wc_notifications.py +130 -0
  96. clawmes/services/zerox.py +243 -0
  97. clawmes/skills/__init__.py +63 -0
  98. clawmes/skills/agent-memory/SKILL.md +63 -0
  99. clawmes/skills/airdrop/SKILL.md +85 -0
  100. clawmes/skills/analytics/SKILL.md +109 -0
  101. clawmes/skills/approvals/SKILL.md +88 -0
  102. clawmes/skills/automation/SKILL.md +84 -0
  103. clawmes/skills/bankr/SKILL.md +99 -0
  104. clawmes/skills/block-explorer/SKILL.md +66 -0
  105. clawmes/skills/bridge/SKILL.md +70 -0
  106. clawmes/skills/browser/SKILL.md +62 -0
  107. clawmes/skills/cost-basis/SKILL.md +61 -0
  108. clawmes/skills/defi-swap/SKILL.md +89 -0
  109. clawmes/skills/defi-trading/SKILL.md +67 -0
  110. clawmes/skills/farcaster/SKILL.md +87 -0
  111. clawmes/skills/governance/SKILL.md +97 -0
  112. clawmes/skills/lending/SKILL.md +74 -0
  113. clawmes/skills/liquidity/SKILL.md +130 -0
  114. clawmes/skills/manage-orders/SKILL.md +66 -0
  115. clawmes/skills/market-intel/SKILL.md +67 -0
  116. clawmes/skills/nft/SKILL.md +95 -0
  117. clawmes/skills/permit2/SKILL.md +96 -0
  118. clawmes/skills/privacy/SKILL.md +60 -0
  119. clawmes/skills/safe-multisig/SKILL.md +99 -0
  120. clawmes/skills/session-recall/SKILL.md +62 -0
  121. clawmes/skills/skill-evolve/SKILL.md +68 -0
  122. clawmes/skills/staking/SKILL.md +70 -0
  123. clawmes/skills/transfer/SKILL.md +67 -0
  124. clawmes/skills/watch-activity/SKILL.md +84 -0
  125. clawmes/tools/__init__.py +128 -0
  126. clawmes/tools/_user_tools.py +121 -0
  127. clawmes/tools/agent_memory.py +106 -0
  128. clawmes/tools/airdrop.py +259 -0
  129. clawmes/tools/analytics.py +384 -0
  130. clawmes/tools/approvals.py +364 -0
  131. clawmes/tools/bankr_automate.py +90 -0
  132. clawmes/tools/bankr_launch.py +101 -0
  133. clawmes/tools/bankr_leverage.py +92 -0
  134. clawmes/tools/bankr_polymarket.py +83 -0
  135. clawmes/tools/block_explorer.py +163 -0
  136. clawmes/tools/bridge.py +375 -0
  137. clawmes/tools/browser.py +148 -0
  138. clawmes/tools/clawnch_fees.py +125 -0
  139. clawmes/tools/clawnch_launch.py +152 -0
  140. clawmes/tools/clawnchconnect.py +203 -0
  141. clawmes/tools/clawnx.py +100 -0
  142. clawmes/tools/compound_action.py +107 -0
  143. clawmes/tools/cost_basis.py +351 -0
  144. clawmes/tools/defi_balance.py +244 -0
  145. clawmes/tools/defi_lend.py +330 -0
  146. clawmes/tools/defi_price.py +125 -0
  147. clawmes/tools/defi_stake.py +220 -0
  148. clawmes/tools/defi_swap.py +460 -0
  149. clawmes/tools/farcaster.py +215 -0
  150. clawmes/tools/giza.py +103 -0
  151. clawmes/tools/governance.py +268 -0
  152. clawmes/tools/herd_intelligence.py +112 -0
  153. clawmes/tools/hummingbot.py +99 -0
  154. clawmes/tools/liquidity.py +413 -0
  155. clawmes/tools/lobster_cash.py +105 -0
  156. clawmes/tools/manage_orders.py +157 -0
  157. clawmes/tools/market_intel.py +170 -0
  158. clawmes/tools/molten.py +104 -0
  159. clawmes/tools/nft.py +374 -0
  160. clawmes/tools/nookplot.py +92 -0
  161. clawmes/tools/paysponge.py +111 -0
  162. clawmes/tools/permit2.py +317 -0
  163. clawmes/tools/privacy.py +112 -0
  164. clawmes/tools/registry.py +270 -0
  165. clawmes/tools/safe.py +228 -0
  166. clawmes/tools/session_recall.py +135 -0
  167. clawmes/tools/skill_evolve.py +140 -0
  168. clawmes/tools/transfer.py +761 -0
  169. clawmes/tools/watch_activity.py +199 -0
  170. clawmes/tools/wayfinder.py +94 -0
  171. clawmes/tools/yield_farming.py +196 -0
  172. clawmes/wallet/__init__.py +25 -0
  173. clawmes/wallet/_base.py +75 -0
  174. clawmes/wallet/bankr.py +165 -0
  175. clawmes/wallet/keystore.py +313 -0
  176. clawmes/wallet/local_key.py +309 -0
  177. clawmes/wallet/state.py +64 -0
  178. clawmes/wallet/walletconnect.py +223 -0
  179. clawmes-0.1.0.dist-info/METADATA +314 -0
  180. clawmes-0.1.0.dist-info/RECORD +184 -0
  181. clawmes-0.1.0.dist-info/WHEEL +5 -0
  182. clawmes-0.1.0.dist-info/entry_points.txt +2 -0
  183. clawmes-0.1.0.dist-info/licenses/LICENSE +21 -0
  184. clawmes-0.1.0.dist-info/top_level.txt +1 -0
clawmes/__init__.py ADDED
@@ -0,0 +1,142 @@
1
+ """Clawmes — Hermes Agent for crypto.
2
+
3
+ A crypto-native plugin for Hermes Agent. Registers tools, commands, hooks,
4
+ skills, and CLI subcommands via Hermes' standard ``register(ctx)`` plugin
5
+ contract.
6
+
7
+ The ``register`` function is the single entry point invoked by
8
+ ``hermes_cli.plugins.PluginManager`` at process startup. It is sync and must
9
+ return quickly. Heavy work (RPC warmup, key validation) is deferred to first
10
+ use or to background threads started by ``services.start_all()``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # When Hermes loads us via ``importlib.util.spec_from_file_location``
16
+ # (e.g. ``hermes plugins install <git>`` path), the module name is a
17
+ # namespaced ``hermes_plugins.clawmes`` rather than the bare
18
+ # ``clawmes``. The rest of this package uses absolute imports
19
+ # (``from clawmes.X import Y``) — without aliasing, those would fail
20
+ # with ``cannot import name 'X' from 'clawmes' (unknown location)``.
21
+ #
22
+ # Aliasing here makes the namespaced module also reachable as
23
+ # ``clawmes``, so subsequent absolute imports resolve to THIS same
24
+ # module instance (no double-load, singletons stay singleton). When
25
+ # loaded via ``pip install`` (canonical bare name from the start),
26
+ # the alias check sees ``clawmes`` already present and skips.
27
+ import sys as _sys
28
+
29
+ if "clawmes" not in _sys.modules:
30
+ _sys.modules["clawmes"] = _sys.modules[__name__]
31
+
32
+ import atexit
33
+ import signal
34
+
35
+ from clawmes import (
36
+ cli,
37
+ commands,
38
+ hooks,
39
+ persona,
40
+ services,
41
+ skills,
42
+ tools,
43
+ )
44
+ from clawmes._version import __version__
45
+ from clawmes.lib.logger import logger_for
46
+
47
+ __all__ = ["__version__", "register"]
48
+
49
+ _log = logger_for("plugin")
50
+
51
+
52
+ def register(ctx) -> None:
53
+ """Plugin entry point called by Hermes at startup.
54
+
55
+ Stages, in order:
56
+
57
+ 1. **Idempotent first-run setup.** ``persona.ensure_soul_md()`` copies
58
+ the bundled SOUL.md into ``${HERMES_HOME}/SOUL.md`` if absent. Never
59
+ overwrites a user-edited file.
60
+ 2. **Surface registration.** Tools, commands, hooks, skills, and CLI
61
+ subcommands are wired through the corresponding ``register_all(ctx)``
62
+ calls. Each subsystem's stub is filled in across subsequent
63
+ milestones; calling them now is a no-op except for ``tools`` once
64
+ individual tool modules land.
65
+ 3. **Service start.** ``services.start_all()`` brings up wallet, RPC,
66
+ price, plan-scheduler, etc. in topological order.
67
+ 4. **Cleanup hooks.** ``atexit`` + SIGTERM/SIGINT handlers ensure
68
+ ``services.stop_all()`` runs on graceful shutdown.
69
+
70
+ Errors during ``register()`` are caught and logged so a clawmes failure
71
+ never crashes Hermes' boot — the plugin manager will mark the plugin
72
+ disabled and surface the error in ``hermes plugins list``.
73
+ """
74
+ _log.info("clawmes %s registering with Hermes", __version__)
75
+
76
+ # 1. First-run setup. If this fails we still continue to surface
77
+ # registration — a missing SOUL.md is a degraded state, not a hard
78
+ # failure.
79
+ _safe("persona.ensure_soul_md", persona.ensure_soul_md)
80
+
81
+ # 2. Register surface — each subsystem is isolated so a buggy
82
+ # commands module can't take down tools, hooks, skills, or the CLI.
83
+ # Hermes shows a partial-feature plugin instead of a fully-disabled
84
+ # one.
85
+ _safe("tools.register_all", tools.register_all, ctx)
86
+ _safe("commands.register_all", commands.register_all, ctx)
87
+ _safe("hooks.register_all", hooks.register_all, ctx)
88
+ _safe("skills.register_all", skills.register_all, ctx)
89
+ _safe("cli.register_all", cli.register_all, ctx)
90
+
91
+ # 3. Background services. start_all itself wraps each service in
92
+ # try/except, so partial failure here means partial feature loss
93
+ # rather than total plugin failure.
94
+ _safe("services.start_all", services.start_all)
95
+
96
+ # 4. Cleanup
97
+ try:
98
+ atexit.register(services.stop_all)
99
+ _install_signal_handlers()
100
+ except Exception:
101
+ _log.exception("failed to install cleanup hooks")
102
+
103
+ _log.info("clawmes register() complete")
104
+
105
+
106
+ def _safe(label: str, fn, *args, **kwargs) -> None:
107
+ """Run ``fn(*args, **kwargs)`` and log any exception without re-raising.
108
+
109
+ Used to isolate register-time failures: a broken module in one
110
+ subsystem (e.g. ``commands``) shouldn't disable every other
111
+ subsystem. If something fails here the user gets a partially-
112
+ functional plugin and a clear log entry instead of a fully-
113
+ disabled plugin and a stack trace in ``hermes plugins list``.
114
+ """
115
+ try:
116
+ fn(*args, **kwargs)
117
+ except Exception:
118
+ _log.exception("clawmes register: %s failed; continuing", label)
119
+
120
+
121
+ def _install_signal_handlers() -> None:
122
+ """Attach SIGTERM/SIGINT handlers for clean service shutdown.
123
+
124
+ Falls through to whatever Hermes already installed — we re-raise after
125
+ stopping our own services so the parent's handlers still fire.
126
+ """
127
+ for sig in (signal.SIGTERM, signal.SIGINT):
128
+ prev = signal.getsignal(sig)
129
+
130
+ def _handler(signum, frame, _prev=prev):
131
+ try:
132
+ services.stop_all()
133
+ finally:
134
+ if callable(_prev) and _prev not in (signal.SIG_DFL, signal.SIG_IGN):
135
+ _prev(signum, frame)
136
+
137
+ try:
138
+ signal.signal(sig, _handler)
139
+ except ValueError:
140
+ # Signal handlers can only be set in the main thread — Hermes
141
+ # may have already done this, fine to skip.
142
+ pass
clawmes/_version.py ADDED
@@ -0,0 +1,10 @@
1
+ """Single-source-of-truth version string.
2
+
3
+ Read by:
4
+ * ``clawmes/__init__.py`` — exposed as ``clawmes.__version__``
5
+ * ``clawmes/cli/version.py`` — emitted by ``hermes clawmes version``
6
+ * ``hermes clawmes doctor`` — reported in diagnostics output
7
+ * Tooling that does not want to incur a full package import
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,24 @@
1
+ """Node sub-process bridges.
2
+
3
+ Two long-lived Node processes are spawned at plugin start:
4
+
5
+ * ``clawmes-wc-bridge`` — wraps ``@walletconnect/sign-client`` for WC v2
6
+ pairing, signing, and session management.
7
+ * ``clawmes-sa-bridge`` — wraps ``@metamask/smart-accounts-kit`` for
8
+ EIP-7702/7710 delegation creation, listing, and execution.
9
+
10
+ Why subprocess? Both upstream JS libs are first-party reference impls
11
+ of in-flux specs; re-implementing in Python is a 6-month project per
12
+ bridge with continuous spec drift. Sub-process keeps the spec-tracking
13
+ burden on upstream.
14
+
15
+ Wire format: JSON-line over stdio. See PRD §21 for the full method
16
+ catalog and protocol.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from clawmes.bridges.installer import ensure_node_bridges
22
+ from clawmes.bridges.process import BridgeProcess
23
+
24
+ __all__ = ["BridgeProcess", "ensure_node_bridges"]
@@ -0,0 +1,126 @@
1
+ """Bridge installer.
2
+
3
+ On every plugin start we make sure the Node bridge sources are present
4
+ and ``npm ci``-installed under ``${HERMES_HOME}/clawmes/bridges/{wc,sa}``.
5
+
6
+ Strategy:
7
+
8
+ 1. Locate the bundled source under
9
+ ``clawmes/bridges/sources/{wc,sa}/``.
10
+ 2. Hash ``package.json`` + ``package-lock.json``.
11
+ 3. Compare against ``${HERMES_HOME}/clawmes/bridges/{wc,sa}/.installed-hash``.
12
+ 4. If changed (or absent):
13
+ a. Copy the bundled source into the install directory
14
+ b. Run ``npm ci --omit=dev``
15
+ c. Write the hash file
16
+ 5. Return absolute paths to ``dist/index.mjs`` for each bridge.
17
+
18
+ If Node is not installed, we log a clear warning and return None — the
19
+ plugin still loads, but tools that need bridges will fail with a
20
+ friendly error.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import shutil
27
+ import subprocess
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from clawmes.lib.logger import logger_for
32
+ from clawmes.lib.paths import bridges_dir
33
+
34
+ _log = logger_for("bridges.installer")
35
+
36
+
37
+ _SOURCES_ROOT = Path(__file__).parent / "sources"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BridgePaths:
42
+ wc_entry: Path | None
43
+ sa_entry: Path | None
44
+
45
+
46
+ def ensure_node_bridges(*, force: bool = False) -> BridgePaths:
47
+ """Idempotent installer for both bridges. Safe to call on every boot."""
48
+ node = shutil.which("node")
49
+ if not node:
50
+ _log.warning(
51
+ "Node.js not found — install Node ≥ 20 for clawmes wallet bridges. "
52
+ "Plugin will load but WC + SA tools will fail."
53
+ )
54
+ return BridgePaths(wc_entry=None, sa_entry=None)
55
+
56
+ wc_entry = _ensure_one("wc", force=force)
57
+ sa_entry = _ensure_one("sa", force=force)
58
+ return BridgePaths(wc_entry=wc_entry, sa_entry=sa_entry)
59
+
60
+
61
+ def _ensure_one(name: str, *, force: bool) -> Path | None:
62
+ src = _SOURCES_ROOT / name
63
+ if not src.exists():
64
+ _log.debug("bridge source for %s missing at %s — not yet bundled", name, src)
65
+ return None
66
+
67
+ target = bridges_dir() / name
68
+ target.mkdir(parents=True, exist_ok=True)
69
+
70
+ pj = src / "package.json"
71
+ pl = src / "package-lock.json"
72
+ if not pj.exists() or not pl.exists():
73
+ _log.debug("bridge source for %s lacks package files yet — skipping install", name)
74
+ return None
75
+
76
+ expected_hash = _hash_files(pj, pl)
77
+ marker = target / ".installed-hash"
78
+ if (
79
+ not force
80
+ and marker.exists()
81
+ and marker.read_text(encoding="utf-8").strip() == expected_hash
82
+ ):
83
+ _log.debug("bridge %s already installed (hash %s)", name, expected_hash[:8])
84
+ return target / "dist" / "index.mjs"
85
+
86
+ _log.info("installing bridge %s …", name)
87
+ _copy_tree(src, target)
88
+ try:
89
+ subprocess.run(
90
+ ["npm", "ci", "--omit=dev"],
91
+ cwd=target,
92
+ check=True,
93
+ capture_output=True,
94
+ text=True,
95
+ )
96
+ except subprocess.CalledProcessError as exc:
97
+ _log.error("npm ci failed for %s: %s", name, exc.stderr)
98
+ return None
99
+
100
+ marker.write_text(expected_hash + "\n", encoding="utf-8")
101
+ return target / "dist" / "index.mjs"
102
+
103
+
104
+ def _hash_files(*paths: Path) -> str:
105
+ h = hashlib.sha256()
106
+ for p in paths:
107
+ h.update(p.read_bytes())
108
+ return h.hexdigest()
109
+
110
+
111
+ def _copy_tree(src: Path, dst: Path) -> None:
112
+ """Mirror ``src`` into ``dst``, preserving file mtimes."""
113
+ if dst.exists():
114
+ # Don't blow away node_modules; just refresh source files.
115
+ for child in src.iterdir():
116
+ if child.name == "node_modules":
117
+ continue
118
+ target = dst / child.name
119
+ if child.is_dir():
120
+ if target.exists():
121
+ shutil.rmtree(target)
122
+ shutil.copytree(child, target)
123
+ else:
124
+ shutil.copy2(child, target)
125
+ else:
126
+ shutil.copytree(src, dst, ignore=shutil.ignore_patterns("node_modules"))
@@ -0,0 +1,263 @@
1
+ """``BridgeProcess`` — long-lived Node subprocess with JSON-line RPC.
2
+
3
+ Manages spawn, stdio reader / writer threads, future-based request /
4
+ response matching, notification dispatch, and graceful shutdown. Per-
5
+ bridge clients (``wc_client``, ``sa_client``) wrap typed methods on
6
+ top of this.
7
+
8
+ Wire format (one record per line, terminated with ``\\n``):
9
+
10
+ Request: ``{"id": "<uuid>", "method": "<name>", "params": {...}}``
11
+ Response OK: ``{"id": "<uuid>", "result": <any>}``
12
+ Response err: ``{"id": "<uuid>", "error": {"code": "<str>", ...}}``
13
+ Notification: ``{"method": "<event>", "params": {...}}`` (no id)
14
+
15
+ Concurrency model:
16
+
17
+ * One **reader thread** per process — pulls lines from stdout,
18
+ parses, dispatches to either the matching pending future
19
+ (response) or the notification queue.
20
+ * The **caller** writes to stdin under a lock, then waits on a
21
+ ``threading.Event`` until the reader resolves it.
22
+ * No event loop — callers can be sync or async; the bridge blocks
23
+ until response arrives or the timeout fires.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import shutil
30
+ import subprocess
31
+ import threading
32
+ import uuid
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from queue import Queue
36
+ from typing import Any
37
+
38
+ from clawmes.lib.logger import logger_for
39
+
40
+ _log = logger_for("bridges.process")
41
+
42
+
43
+ class BridgeError(RuntimeError):
44
+ """Raised when a bridge call fails (crash, timeout, RPC error)."""
45
+
46
+ def __init__(self, code: str, message: str, *, data: Any = None) -> None:
47
+ super().__init__(message)
48
+ self.code = code
49
+ self.data = data
50
+
51
+
52
+ @dataclass
53
+ class Notification:
54
+ """Server-pushed event from a bridge (no ``id`` field)."""
55
+
56
+ method: str
57
+ params: dict[str, Any]
58
+
59
+
60
+ @dataclass
61
+ class _PendingCall:
62
+ event: threading.Event = field(default_factory=threading.Event)
63
+ result: Any = None
64
+ error: BridgeError | None = None
65
+
66
+
67
+ class BridgeProcess:
68
+ """Lifecycle wrapper around a single Node bridge subprocess."""
69
+
70
+ def __init__(
71
+ self,
72
+ name: str,
73
+ entry: Path,
74
+ *,
75
+ node_bin: str | None = None,
76
+ env: dict[str, str] | None = None,
77
+ ) -> None:
78
+ self.name = name
79
+ self.entry = entry
80
+ self._node_bin = node_bin or shutil.which("node") or "node"
81
+ self._env = env
82
+ self._proc: subprocess.Popen | None = None
83
+ self._reader: threading.Thread | None = None
84
+ self._stderr_reader: threading.Thread | None = None
85
+ self._writer_lock = threading.Lock()
86
+ self._pending: dict[str, _PendingCall] = {}
87
+ self._pending_lock = threading.Lock()
88
+ self._notifications: Queue[Notification] = Queue()
89
+ self._lock = threading.RLock()
90
+ self._stopping = False
91
+
92
+ # ----- lifecycle ----------------------------------------------------
93
+
94
+ def start(self) -> None:
95
+ with self._lock:
96
+ if self._proc is not None and self._proc.poll() is None:
97
+ return
98
+ _log.info("starting bridge %s: %s %s", self.name, self._node_bin, self.entry)
99
+ self._stopping = False
100
+ self._proc = subprocess.Popen(
101
+ [self._node_bin, str(self.entry)],
102
+ stdin=subprocess.PIPE,
103
+ stdout=subprocess.PIPE,
104
+ stderr=subprocess.PIPE,
105
+ bufsize=1, # line-buffered
106
+ text=True,
107
+ env=self._env,
108
+ )
109
+ self._reader = threading.Thread(
110
+ target=self._read_stdout,
111
+ name=f"{self.name}-reader",
112
+ daemon=True,
113
+ )
114
+ self._reader.start()
115
+ self._stderr_reader = threading.Thread(
116
+ target=self._read_stderr,
117
+ name=f"{self.name}-stderr",
118
+ daemon=True,
119
+ )
120
+ self._stderr_reader.start()
121
+
122
+ def stop(self) -> None:
123
+ with self._lock:
124
+ self._stopping = True
125
+ if self._proc is None:
126
+ return
127
+ if self._proc.poll() is None:
128
+ try:
129
+ self._proc.terminate()
130
+ self._proc.wait(timeout=5)
131
+ except subprocess.TimeoutExpired:
132
+ self._proc.kill()
133
+ try:
134
+ self._proc.wait(timeout=2)
135
+ except subprocess.TimeoutExpired:
136
+ pass
137
+ # Fail every pending call with a clean error
138
+ with self._pending_lock:
139
+ for call in self._pending.values():
140
+ call.error = BridgeError("bridge_stopped", f"bridge {self.name} stopped")
141
+ call.event.set()
142
+ self._pending.clear()
143
+ self._proc = None
144
+
145
+ def is_running(self) -> bool:
146
+ return self._proc is not None and self._proc.poll() is None
147
+
148
+ # ----- request / response ------------------------------------------
149
+
150
+ def call(self, method: str, params: dict[str, Any], *, timeout: float = 30.0) -> Any:
151
+ """Send a request, wait for the matching response."""
152
+ if not self.is_running():
153
+ raise BridgeError("not_running", f"bridge {self.name} is not running")
154
+
155
+ request_id = str(uuid.uuid4())
156
+ call = _PendingCall()
157
+ with self._pending_lock:
158
+ self._pending[request_id] = call
159
+
160
+ line = json.dumps({"id": request_id, "method": method, "params": params})
161
+ try:
162
+ with self._writer_lock:
163
+ assert self._proc is not None and self._proc.stdin is not None
164
+ self._proc.stdin.write(line + "\n")
165
+ self._proc.stdin.flush()
166
+ except (BrokenPipeError, OSError) as exc:
167
+ with self._pending_lock:
168
+ self._pending.pop(request_id, None)
169
+ raise BridgeError(
170
+ "write_failed", f"bridge {self.name} stdin write failed: {exc}"
171
+ ) from exc
172
+
173
+ if not call.event.wait(timeout):
174
+ with self._pending_lock:
175
+ self._pending.pop(request_id, None)
176
+ raise BridgeError(
177
+ "timeout", f"bridge {self.name} timeout after {timeout}s on {method!r}"
178
+ )
179
+
180
+ if call.error is not None:
181
+ raise call.error
182
+ return call.result
183
+
184
+ def notifications(self) -> Queue[Notification]:
185
+ return self._notifications
186
+
187
+ # ----- internals ---------------------------------------------------
188
+
189
+ def _read_stdout(self) -> None:
190
+ """Reader thread — parses each line, routes to pending future or notification queue."""
191
+ proc = self._proc
192
+ if proc is None or proc.stdout is None:
193
+ return
194
+ for raw in iter(proc.stdout.readline, ""):
195
+ line = raw.strip()
196
+ if not line:
197
+ continue
198
+ try:
199
+ payload = json.loads(line)
200
+ except json.JSONDecodeError:
201
+ _log.warning("%s: malformed line: %r", self.name, line[:200])
202
+ continue
203
+ self._handle_payload(payload)
204
+ # Reader exiting — process closed stdout. If we have pending
205
+ # calls and we're not in a stop() pathway, surface that as an
206
+ # error.
207
+ if not self._stopping:
208
+ with self._pending_lock:
209
+ pending = list(self._pending.items())
210
+ self._pending.clear()
211
+ for request_id, call in pending:
212
+ call.error = BridgeError(
213
+ "bridge_crashed",
214
+ f"bridge {self.name} stdout closed with pending request {request_id}",
215
+ )
216
+ call.event.set()
217
+
218
+ def _read_stderr(self) -> None:
219
+ """Stderr drain — tag every line with the bridge name and forward to logger."""
220
+ proc = self._proc
221
+ if proc is None or proc.stderr is None:
222
+ return
223
+ for raw in iter(proc.stderr.readline, ""):
224
+ line = raw.strip()
225
+ if line:
226
+ _log.warning("%s [stderr]: %s", self.name, line)
227
+
228
+ def _handle_payload(self, payload: Any) -> None:
229
+ if not isinstance(payload, dict):
230
+ _log.warning("%s: non-dict payload: %r", self.name, payload)
231
+ return
232
+ request_id = payload.get("id")
233
+ if request_id is None:
234
+ self._enqueue_notification(payload)
235
+ return
236
+ with self._pending_lock:
237
+ call = self._pending.pop(str(request_id), None)
238
+ if call is None:
239
+ _log.debug("%s: response for unknown id %r", self.name, request_id)
240
+ return
241
+ if "error" in payload and payload["error"]:
242
+ err = payload["error"]
243
+ if isinstance(err, dict):
244
+ call.error = BridgeError(
245
+ code=str(err.get("code", "unknown")),
246
+ message=str(err.get("message", "")),
247
+ data=err.get("data"),
248
+ )
249
+ else:
250
+ call.error = BridgeError("unknown", str(err))
251
+ else:
252
+ call.result = payload.get("result")
253
+ call.event.set()
254
+
255
+ def _enqueue_notification(self, payload: dict[str, Any]) -> None:
256
+ method = payload.get("method")
257
+ if not isinstance(method, str):
258
+ _log.warning("%s: notification missing method: %r", self.name, payload)
259
+ return
260
+ params = payload.get("params") or {}
261
+ if not isinstance(params, dict):
262
+ params = {}
263
+ self._notifications.put(Notification(method=method, params=params))
@@ -0,0 +1,96 @@
1
+ """Python client for ``clawmes-sa-bridge`` (MetaMask Smart Accounts).
2
+
3
+ Typed wrappers for EIP-7702 / 7710 delegation work and Permit2 signing.
4
+ Methods (see PRD §21.3):
5
+
6
+ * :meth:`delegation_create`
7
+ * :meth:`delegation_list`
8
+ * :meth:`delegation_revoke`
9
+ * :meth:`delegation_execute` — used by the ``@write_tool`` gating
10
+ pipeline as stage 3 (skip handler if delegation handles it)
11
+ * :meth:`account_deploy`
12
+ * :meth:`permit2_sign`
13
+ * :meth:`health`
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from clawmes.bridges.process import BridgeProcess
22
+ from clawmes.lib.logger import logger_for
23
+
24
+ _log = logger_for("bridges.sa")
25
+
26
+
27
+ class SmartAccountsClient:
28
+ def __init__(self, entry: Path, *, node_bin: str = "node") -> None:
29
+ self._proc = BridgeProcess("clawmes-sa", entry, node_bin=node_bin)
30
+
31
+ def start(self) -> None:
32
+ self._proc.start()
33
+
34
+ def stop(self) -> None:
35
+ self._proc.stop()
36
+
37
+ def delegation_create(
38
+ self,
39
+ *,
40
+ delegate: str,
41
+ permissions: list[dict[str, Any]],
42
+ expiry: int,
43
+ ) -> dict[str, Any]:
44
+ return self._proc.call(
45
+ "delegation_create",
46
+ {"delegate": delegate, "permissions": permissions, "expiry": expiry},
47
+ )
48
+
49
+ def delegation_list(self) -> list[dict[str, Any]]:
50
+ result = self._proc.call("delegation_list", {})
51
+ return list(result.get("delegations", []))
52
+
53
+ def delegation_revoke(self, delegation_id: str) -> str:
54
+ result = self._proc.call("delegation_revoke", {"delegation_id": delegation_id})
55
+ return result["tx_hash"]
56
+
57
+ def delegation_execute(
58
+ self,
59
+ *,
60
+ delegation_id: str,
61
+ calldata: str,
62
+ to: str,
63
+ value: str = "0x0",
64
+ chain_id: int,
65
+ ) -> str:
66
+ result = self._proc.call(
67
+ "delegation_execute",
68
+ {
69
+ "delegation_id": delegation_id,
70
+ "calldata": calldata,
71
+ "to": to,
72
+ "value": value,
73
+ "chain_id": chain_id,
74
+ },
75
+ timeout=60.0,
76
+ )
77
+ return result["tx_hash"]
78
+
79
+ def account_deploy(self, chain_id: int) -> dict[str, Any]:
80
+ return self._proc.call("account_deploy", {"chain_id": chain_id})
81
+
82
+ def permit2_sign(
83
+ self,
84
+ *,
85
+ token: str,
86
+ spender: str,
87
+ amount: str,
88
+ deadline: int,
89
+ ) -> dict[str, Any]:
90
+ return self._proc.call(
91
+ "permit2_sign",
92
+ {"token": token, "spender": spender, "amount": amount, "deadline": deadline},
93
+ )
94
+
95
+ def health(self) -> dict[str, Any]:
96
+ return self._proc.call("health", {})