@clawpump/claw-agent 0.1.7 → 0.1.9
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/agent/.mailmap +4 -0
- package/agent/apps/desktop/README.md +3 -3
- package/agent/apps/desktop/assets/icon.icns +0 -0
- package/agent/apps/desktop/assets/icon.ico +0 -0
- package/agent/apps/desktop/assets/icon.png +0 -0
- package/agent/apps/desktop/electron/backend-ready.cjs +2 -2
- package/agent/apps/desktop/electron/dashboard-token.cjs +3 -3
- package/agent/apps/desktop/electron/hardening.cjs +1 -1
- package/agent/apps/desktop/electron/main.cjs +65 -65
- package/agent/apps/desktop/index.html +1 -1
- package/agent/apps/desktop/package.json +11 -11
- package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
- package/agent/apps/desktop/public/claw-mark.png +0 -0
- package/agent/apps/desktop/scripts/set-exe-identity.cjs +2 -2
- package/agent/apps/desktop/src/app/chat/composer/controls.tsx +2 -0
- package/agent/apps/desktop/src/app/chat/composer/index.tsx +10 -0
- package/agent/apps/desktop/src/app/chat/composer/pod-credits.tsx +49 -0
- package/agent/apps/desktop/src/app/chat/index.tsx +1 -1
- package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +4 -2
- package/agent/apps/desktop/src/app/desktop-controller.tsx +18 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +1 -1
- package/agent/apps/desktop/src/app/messaging/index.tsx +5 -5
- package/agent/apps/desktop/src/app/routes.ts +9 -1
- package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +3 -3
- package/agent/apps/desktop/src/app/settings/constants.ts +5 -5
- package/agent/apps/desktop/src/app/settings/model-settings.tsx +1 -1
- package/agent/apps/desktop/src/app/settings/providers-settings.tsx +46 -1
- package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +5 -5
- package/agent/apps/desktop/src/app/types.ts +9 -1
- package/agent/apps/desktop/src/app/wallet/index.tsx +244 -0
- package/agent/apps/desktop/src/app/x402/index.tsx +162 -0
- package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +1 -1
- package/agent/apps/desktop/src/components/brand-mark.tsx +2 -2
- package/agent/apps/desktop/src/components/chat/intro-copy.jsonl +6 -6
- package/agent/apps/desktop/src/components/chat/intro.tsx +4 -4
- package/agent/apps/desktop/src/components/model-picker.tsx +64 -4
- package/agent/apps/desktop/src/components/pod-setup-dialog.tsx +227 -0
- package/agent/apps/desktop/src/hermes.ts +109 -3
- package/agent/apps/desktop/src/i18n/en.ts +80 -78
- package/agent/apps/desktop/src/i18n/ja.ts +82 -82
- package/agent/apps/desktop/src/i18n/runtime.test.ts +2 -2
- package/agent/apps/desktop/src/i18n/zh-hant.ts +82 -82
- package/agent/apps/desktop/src/i18n/zh.ts +87 -87
- package/agent/apps/desktop/src/lib/desktop-fs.ts +1 -1
- package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +4 -4
- package/agent/apps/desktop/src/store/composer.ts +7 -0
- package/agent/apps/desktop/src/store/onboarding.ts +5 -5
- package/agent/apps/desktop/src/themes/presets.ts +54 -54
- package/agent/cli.py +184 -10
- package/agent/hermes_cli/distribution.py +188 -8
- package/agent/hermes_cli/providers.py +29 -0
- package/agent/hermes_cli/web_server.py +403 -34
- package/agent/plugins/model-providers/usepod/__init__.py +7 -1
- package/agent/scripts/release.py +1 -0
- package/agent/web/public/claw-logo.png +0 -0
- package/agent/web/src/App.tsx +6 -4
- package/agent/web/src/components/ChatSidebar.tsx +5 -0
- package/agent/web/src/components/ModelPickerDialog.tsx +28 -1
- package/agent/web/src/components/PodCredits.tsx +57 -0
- package/agent/web/src/components/PodSetupDialog.tsx +240 -0
- package/agent/web/src/lib/api.ts +135 -0
- package/agent/web/src/pages/AgentMailPage.tsx +684 -0
- package/agent/web/src/pages/WalletPage.tsx +53 -5
- package/package.json +1 -1
|
@@ -12081,52 +12081,289 @@ def _parse_mcp_json(text: str) -> Any:
|
|
|
12081
12081
|
return data
|
|
12082
12082
|
|
|
12083
12083
|
|
|
12084
|
+
# ── Warm ClawPump MCP session (shared by the dashboard's MCP routes) ──────
|
|
12085
|
+
# Keep ONE MCP session alive and reuse it — the same thing the agent runtime
|
|
12086
|
+
# does, which is why chat kept working while the dashboard didn't. Opening a
|
|
12087
|
+
# fresh connection per request stalls the shared MCP event loop under OAuth
|
|
12088
|
+
# token churn and wedges every page until a restart. One warm session plus a
|
|
12089
|
+
# single reconnect-on-failure stays fast and self-heals. The lock serializes
|
|
12090
|
+
# calls (one JSON-RPC stream per session); the short timeout means a stall
|
|
12091
|
+
# fails fast instead of hanging forever.
|
|
12092
|
+
_warm_mcp_lock = threading.Lock()
|
|
12093
|
+
_warm_mcp_server: Any = None # live MCPServerTask, kept warm by its keepalive ping
|
|
12094
|
+
|
|
12095
|
+
|
|
12096
|
+
class _McpToolError(RuntimeError):
|
|
12097
|
+
"""The tool ran but returned an error — the session itself is healthy."""
|
|
12098
|
+
|
|
12099
|
+
|
|
12100
|
+
def _clawpump_call(
|
|
12101
|
+
tool: str, arguments: Optional[dict] = None, *, timeout: float = 20, prefer_structured: bool = False
|
|
12102
|
+
) -> Any:
|
|
12103
|
+
"""Call a ClawPump MCP tool over the dashboard's warm, reused session.
|
|
12104
|
+
|
|
12105
|
+
Reuses one long-lived connection and reconnects only if the session has
|
|
12106
|
+
died, so the dashboard behaves like the agent's persistent connection
|
|
12107
|
+
instead of reconnecting (and stalling) per request. Returns parsed JSON;
|
|
12108
|
+
raises ``RuntimeError`` when the MCP is unconfigured or the tool errors.
|
|
12109
|
+
|
|
12110
|
+
``prefer_structured`` returns the tool's ``structuredContent`` (the machine
|
|
12111
|
+
channel) when present, instead of the human-visible text. Needed for tools
|
|
12112
|
+
that REDACT secrets in the text but put the real value in structuredContent
|
|
12113
|
+
(e.g. usepod_provision returns a placeholder api_token in text and the real
|
|
12114
|
+
token in structuredContent). Default off so existing callers are unchanged.
|
|
12115
|
+
"""
|
|
12116
|
+
global _warm_mcp_server
|
|
12117
|
+
|
|
12118
|
+
name, cfg = _clawpump_mcp()
|
|
12119
|
+
if not name:
|
|
12120
|
+
raise RuntimeError("ClawPump MCP is not configured. Run `hermes clawpump setup`.")
|
|
12121
|
+
|
|
12122
|
+
from tools.mcp_tool import _ensure_mcp_loop, _run_on_mcp_loop, _connect_server
|
|
12123
|
+
from hermes_cli.mcp_config import _resolve_mcp_server_config
|
|
12124
|
+
|
|
12125
|
+
cfg = _resolve_mcp_server_config(cfg)
|
|
12126
|
+
_ensure_mcp_loop()
|
|
12127
|
+
|
|
12128
|
+
def _invoke(server: Any) -> Any:
|
|
12129
|
+
async def _coro():
|
|
12130
|
+
result = await server.session.call_tool(tool, arguments=arguments or {})
|
|
12131
|
+
text = "".join(b.text for b in (result.content or []) if hasattr(b, "text"))
|
|
12132
|
+
if getattr(result, "isError", False):
|
|
12133
|
+
raise _McpToolError(text or "MCP tool returned an error")
|
|
12134
|
+
if prefer_structured:
|
|
12135
|
+
sc = getattr(result, "structuredContent", None)
|
|
12136
|
+
if isinstance(sc, dict) and sc:
|
|
12137
|
+
return sc
|
|
12138
|
+
return text
|
|
12139
|
+
|
|
12140
|
+
raw = _run_on_mcp_loop(_coro, timeout=timeout)
|
|
12141
|
+
data = raw if isinstance(raw, dict) else _parse_mcp_json(raw)
|
|
12142
|
+
if isinstance(data, dict) and isinstance(data.get("error"), str) and data["error"]:
|
|
12143
|
+
raise _McpToolError(data["error"])
|
|
12144
|
+
return data
|
|
12145
|
+
|
|
12146
|
+
with _warm_mcp_lock:
|
|
12147
|
+
server = _warm_mcp_server
|
|
12148
|
+
if server is not None:
|
|
12149
|
+
try:
|
|
12150
|
+
return _invoke(server)
|
|
12151
|
+
except _McpToolError:
|
|
12152
|
+
raise # the session is fine — the tool itself returned an error
|
|
12153
|
+
except Exception:
|
|
12154
|
+
_warm_mcp_server = None # transport died — drop it and reconnect
|
|
12155
|
+
try:
|
|
12156
|
+
_run_on_mcp_loop(lambda: server.shutdown(), timeout=5)
|
|
12157
|
+
except Exception:
|
|
12158
|
+
pass
|
|
12159
|
+
server = _run_on_mcp_loop(lambda: _connect_server(name, cfg), timeout=timeout)
|
|
12160
|
+
_warm_mcp_server = server
|
|
12161
|
+
return _invoke(server)
|
|
12162
|
+
|
|
12163
|
+
|
|
12084
12164
|
@app.get("/api/wallet/balances")
|
|
12085
12165
|
def get_wallet_balances():
|
|
12086
12166
|
"""Agent wallet balances (name + address + SOL + USDC) via the ClawPump MCP.
|
|
12087
12167
|
|
|
12088
12168
|
Calls ``get_wallet_summaries`` (and ``list_agents`` for display names) over
|
|
12089
|
-
|
|
12090
|
-
Read-only. Sync def so FastAPI runs it in a threadpool — MCP call blocks.
|
|
12169
|
+
the dashboard's warm MCP session. Read-only. Sync def — MCP call blocks.
|
|
12091
12170
|
"""
|
|
12092
|
-
from hermes_cli.mcp_config import _call_single_tool
|
|
12093
|
-
|
|
12094
|
-
srv_name, srv_cfg = _clawpump_mcp()
|
|
12095
|
-
if not srv_name:
|
|
12096
|
-
return {
|
|
12097
|
-
"ok": False,
|
|
12098
|
-
"error": "ClawPump MCP is not configured. Run `hermes clawpump setup`.",
|
|
12099
|
-
"wallets": [],
|
|
12100
|
-
}
|
|
12101
|
-
|
|
12102
12171
|
try:
|
|
12103
|
-
|
|
12172
|
+
data = _clawpump_call("get_wallet_summaries", {})
|
|
12104
12173
|
except Exception as exc: # surface any connection / tool error to the UI
|
|
12105
12174
|
return {"ok": False, "error": str(exc), "wallets": []}
|
|
12106
12175
|
|
|
12107
|
-
data = _parse_mcp_json(text)
|
|
12108
12176
|
wallets = data if isinstance(data, list) else []
|
|
12109
12177
|
|
|
12110
12178
|
# Enrich with the agent's display name — the summaries only carry the
|
|
12111
12179
|
# agent_id UUID. Best-effort: a failure here just leaves ``name`` unset.
|
|
12112
12180
|
try:
|
|
12113
|
-
agents =
|
|
12181
|
+
agents = _clawpump_call("list_agents", {})
|
|
12114
12182
|
if isinstance(agents, dict):
|
|
12115
12183
|
agents = agents.get("agents")
|
|
12116
|
-
|
|
12117
|
-
a["id"]: a
|
|
12184
|
+
by_id = {
|
|
12185
|
+
a["id"]: a
|
|
12118
12186
|
for a in (agents or [])
|
|
12119
12187
|
if isinstance(a, dict) and a.get("id")
|
|
12120
12188
|
}
|
|
12121
12189
|
for w in wallets:
|
|
12122
12190
|
if isinstance(w, dict):
|
|
12123
|
-
|
|
12191
|
+
meta = by_id.get(w.get("agent_id")) or {}
|
|
12192
|
+
w["name"] = meta.get("name")
|
|
12193
|
+
w["token_mint"] = meta.get("token_mint")
|
|
12194
|
+
# avatar_url is fetched lazily via /api/agent/avatar — list_agents
|
|
12195
|
+
# nulls it; only the single get_agent returns it.
|
|
12124
12196
|
except Exception:
|
|
12125
12197
|
pass
|
|
12126
12198
|
|
|
12127
12199
|
return {"ok": True, "wallets": wallets}
|
|
12128
12200
|
|
|
12129
12201
|
|
|
12202
|
+
class PodProvisionBody(BaseModel):
|
|
12203
|
+
agent_id: str
|
|
12204
|
+
amount: float
|
|
12205
|
+
|
|
12206
|
+
|
|
12207
|
+
@app.post("/api/clawpump/pod/provision")
|
|
12208
|
+
async def post_pod_provision(body: PodProvisionBody, profile: Optional[str] = None):
|
|
12209
|
+
"""Provision a UsePod "Pod" and switch the agent onto it as the provider.
|
|
12210
|
+
|
|
12211
|
+
One ClawPump-wallet-funded call: ``usepod_provision`` registers a fresh pod
|
|
12212
|
+
and funds it with ``amount`` USDC from ``agent_id``'s wallet (double-gated by
|
|
12213
|
+
``confirm_deposit``), returning an ``api_token``. We persist the token
|
|
12214
|
+
(USEPOD_API_KEY) and set ``model.provider=usepod`` so the session uses Pod —
|
|
12215
|
+
the desktop's one-click "Set up Pod" flow, mirroring the CLI Pod picker.
|
|
12216
|
+
|
|
12217
|
+
Spends real on-chain USDC; the UI confirms the amount before calling this.
|
|
12218
|
+
Sync body runs in a worker thread (MCP + ``_profile_scope`` both block).
|
|
12219
|
+
"""
|
|
12220
|
+
agent_id = (body.agent_id or "").strip()
|
|
12221
|
+
try:
|
|
12222
|
+
amount = float(body.amount)
|
|
12223
|
+
except (TypeError, ValueError):
|
|
12224
|
+
raise HTTPException(status_code=400, detail="amount must be a number")
|
|
12225
|
+
if not agent_id:
|
|
12226
|
+
raise HTTPException(status_code=400, detail="agent_id is required")
|
|
12227
|
+
if amount <= 0:
|
|
12228
|
+
raise HTTPException(status_code=400, detail="amount must be positive")
|
|
12229
|
+
|
|
12230
|
+
def _unwrap(value: "Any") -> "Any":
|
|
12231
|
+
# ClawPump MCP results can arrive double-wrapped: {"result": "<escaped
|
|
12232
|
+
# json>"} or {"structuredContent": {...}}. Peel those so we see the real
|
|
12233
|
+
# {api_token, deposit_code, signature, funding_error} object.
|
|
12234
|
+
seen = 0
|
|
12235
|
+
while isinstance(value, str) and seen < 3:
|
|
12236
|
+
try:
|
|
12237
|
+
value = json.loads(value)
|
|
12238
|
+
seen += 1
|
|
12239
|
+
except Exception:
|
|
12240
|
+
break
|
|
12241
|
+
if isinstance(value, dict):
|
|
12242
|
+
sc = value.get("structuredContent")
|
|
12243
|
+
if isinstance(sc, dict):
|
|
12244
|
+
return sc
|
|
12245
|
+
if set(value.keys()) <= {"result"} and value.get("result") is not None:
|
|
12246
|
+
return _unwrap(value["result"])
|
|
12247
|
+
return value
|
|
12248
|
+
|
|
12249
|
+
def _provision_and_apply():
|
|
12250
|
+
from hermes_cli import distribution as _dist
|
|
12251
|
+
|
|
12252
|
+
raw = _clawpump_call(
|
|
12253
|
+
"usepod_provision",
|
|
12254
|
+
{"amount": amount, "agent_id": agent_id, "confirm_deposit": True},
|
|
12255
|
+
timeout=120,
|
|
12256
|
+
# The real api_token is only in structuredContent — the text channel
|
|
12257
|
+
# redacts it to a placeholder. Without this we'd persist the
|
|
12258
|
+
# placeholder as USEPOD_API_KEY and Pod would look set but not work.
|
|
12259
|
+
prefer_structured=True,
|
|
12260
|
+
)
|
|
12261
|
+
# Token extraction goes through the battle-tested downstream helper
|
|
12262
|
+
# (tolerates nesting/escaping); signature is best-effort from _unwrap.
|
|
12263
|
+
tok = _dist.usepod_provision_token("mcp_clawpump_usepod_provision", raw)
|
|
12264
|
+
data = _unwrap(raw)
|
|
12265
|
+
data = data if isinstance(data, dict) else {}
|
|
12266
|
+
if tok:
|
|
12267
|
+
api_token, deposit_code = tok
|
|
12268
|
+
else:
|
|
12269
|
+
api_token = str(data.get("api_token") or data.get("token") or "").strip()
|
|
12270
|
+
deposit_code = str(data.get("deposit_code") or "").strip()
|
|
12271
|
+
if not api_token:
|
|
12272
|
+
detail = str(data.get("funding_error") or data.get("error") or "no api_token returned")
|
|
12273
|
+
raise RuntimeError(f"Pod provisioning failed: {detail}")
|
|
12274
|
+
|
|
12275
|
+
_dist.persist_usepod_credentials(api_token, deposit_code)
|
|
12276
|
+
|
|
12277
|
+
current = ""
|
|
12278
|
+
try:
|
|
12279
|
+
from hermes_cli.config import load_config
|
|
12280
|
+
|
|
12281
|
+
current = str((load_config().get("model") or {}).get("default") or "")
|
|
12282
|
+
except Exception:
|
|
12283
|
+
current = ""
|
|
12284
|
+
model, _provider, _base_url = _dist.usepod_pod_switch_target(api_token, current)
|
|
12285
|
+
with _profile_scope(body.profile if hasattr(body, "profile") else profile):
|
|
12286
|
+
_apply_model_assignment_sync("main", "usepod", model, "", "", "")
|
|
12287
|
+
|
|
12288
|
+
return {
|
|
12289
|
+
"ok": True,
|
|
12290
|
+
"provider": "usepod",
|
|
12291
|
+
"model": model,
|
|
12292
|
+
"amount": amount,
|
|
12293
|
+
"signature": str(data.get("signature") or ""),
|
|
12294
|
+
"funding_error": str(data.get("funding_error") or ""),
|
|
12295
|
+
}
|
|
12296
|
+
|
|
12297
|
+
try:
|
|
12298
|
+
return await asyncio.to_thread(_provision_and_apply)
|
|
12299
|
+
except HTTPException:
|
|
12300
|
+
raise
|
|
12301
|
+
except Exception as exc:
|
|
12302
|
+
_log.exception("POST /api/clawpump/pod/provision failed")
|
|
12303
|
+
return {"ok": False, "error": str(exc)}
|
|
12304
|
+
|
|
12305
|
+
|
|
12306
|
+
# Last successfully-fetched Pod balance, keyed by token. A transient probe
|
|
12307
|
+
# failure (Cloudflare hiccup / timeout) then falls back to the last good value
|
|
12308
|
+
# so the UI never flickers from "$0.96" back to a bare "Pod". {token: usdc}.
|
|
12309
|
+
_pod_balance_cache: Dict[str, float] = {}
|
|
12310
|
+
|
|
12311
|
+
|
|
12312
|
+
def _fetch_pod_balance(token: str) -> Optional[float]:
|
|
12313
|
+
"""Fetch the pod's USDC balance (UI units). Retries once; None on failure."""
|
|
12314
|
+
from hermes_cli.auth import USEPOD_API_BASE
|
|
12315
|
+
from providers.base import _profile_user_agent
|
|
12316
|
+
|
|
12317
|
+
url = f"{USEPOD_API_BASE}/proxy/{token}/balance"
|
|
12318
|
+
for attempt in range(2):
|
|
12319
|
+
try:
|
|
12320
|
+
# UsePod sits behind Cloudflare, which 403s ("error 1010") a bare
|
|
12321
|
+
# urllib request — send a browser-ish UA + the bearer token, matching
|
|
12322
|
+
# the provider's own fetch_models. The token also lives in the path.
|
|
12323
|
+
req = urllib.request.Request(url, method="GET")
|
|
12324
|
+
req.add_header("User-Agent", _profile_user_agent())
|
|
12325
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
12326
|
+
req.add_header("Accept", "application/json")
|
|
12327
|
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
12328
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
12329
|
+
micro = data.get("usdc_balance")
|
|
12330
|
+
if micro is not None:
|
|
12331
|
+
return round(float(micro) / 1_000_000.0, 4)
|
|
12332
|
+
return None
|
|
12333
|
+
except Exception:
|
|
12334
|
+
if attempt == 0:
|
|
12335
|
+
continue
|
|
12336
|
+
return None
|
|
12337
|
+
return None
|
|
12338
|
+
|
|
12339
|
+
|
|
12340
|
+
@app.get("/api/clawpump/pod/status")
|
|
12341
|
+
def get_pod_status():
|
|
12342
|
+
"""Whether a UsePod Pod is configured + its remaining USDC balance.
|
|
12343
|
+
|
|
12344
|
+
Lets the desktop show Pod as "Connected" (with balance) instead of "Set up"
|
|
12345
|
+
once provisioned. Reads USEPOD_API_KEY from ~/.hermes/.env (never returns the
|
|
12346
|
+
token) and best-effort fetches the pod's balance — falling back to the last
|
|
12347
|
+
cached value on a transient failure so the credits readout stays stable.
|
|
12348
|
+
"""
|
|
12349
|
+
from hermes_cli.config import get_env_value
|
|
12350
|
+
|
|
12351
|
+
token = (get_env_value("USEPOD_API_KEY") or "").strip()
|
|
12352
|
+
# Empty, or the redacted placeholder from the old token-handling bug, both
|
|
12353
|
+
# mean "not really connected".
|
|
12354
|
+
if not token or token.startswith("<") or "applied" in token.lower():
|
|
12355
|
+
return {"connected": False}
|
|
12356
|
+
|
|
12357
|
+
balance_usdc = _fetch_pod_balance(token)
|
|
12358
|
+
if balance_usdc is not None:
|
|
12359
|
+
_pod_balance_cache[token] = balance_usdc
|
|
12360
|
+
else:
|
|
12361
|
+
# Probe failed this time — show the last known balance if we have one.
|
|
12362
|
+
balance_usdc = _pod_balance_cache.get(token)
|
|
12363
|
+
|
|
12364
|
+
return {"connected": True, "balance_usdc": balance_usdc}
|
|
12365
|
+
|
|
12366
|
+
|
|
12130
12367
|
class WalletTransferBody(BaseModel):
|
|
12131
12368
|
agent_id: str
|
|
12132
12369
|
to: str
|
|
@@ -12196,31 +12433,16 @@ def x402_search(q: str = "", network: str = "solana"):
|
|
|
12196
12433
|
The ClawPump wallet settles x402 in USDC on Solana, so the default is
|
|
12197
12434
|
``solana``; pass ``network=all`` to see every chain. Sync def — MCP blocks.
|
|
12198
12435
|
"""
|
|
12199
|
-
from hermes_cli.mcp_config import _call_single_tool
|
|
12200
|
-
|
|
12201
12436
|
query = (q or "").strip()
|
|
12202
12437
|
if not query:
|
|
12203
12438
|
return {"ok": True, "query": "", "network": network, "results": []}
|
|
12204
12439
|
|
|
12205
|
-
srv_name, srv_cfg = _clawpump_mcp()
|
|
12206
|
-
if not srv_name:
|
|
12207
|
-
return {
|
|
12208
|
-
"ok": False,
|
|
12209
|
-
"error": "ClawPump MCP is not configured. Run `hermes clawpump setup`.",
|
|
12210
|
-
"results": [],
|
|
12211
|
-
}
|
|
12212
|
-
|
|
12213
12440
|
try:
|
|
12214
|
-
|
|
12215
|
-
srv_name, srv_cfg, "dexter_search", {"query": query, "network": network}
|
|
12216
|
-
)
|
|
12441
|
+
data = _clawpump_call("dexter_search", {"query": query, "network": network})
|
|
12217
12442
|
except Exception as exc: # surface any connection / tool error to the UI
|
|
12218
12443
|
return {"ok": False, "error": str(exc), "results": []}
|
|
12219
12444
|
|
|
12220
|
-
data = _parse_mcp_json(text)
|
|
12221
12445
|
if isinstance(data, dict):
|
|
12222
|
-
if isinstance(data.get("error"), str) and data["error"]:
|
|
12223
|
-
return {"ok": False, "error": data["error"], "results": []}
|
|
12224
12446
|
results = data.get("results")
|
|
12225
12447
|
results = results if isinstance(results, list) else []
|
|
12226
12448
|
elif isinstance(data, list):
|
|
@@ -12230,6 +12452,153 @@ def x402_search(q: str = "", network: str = "solana"):
|
|
|
12230
12452
|
return {"ok": True, "query": query, "network": network, "results": results}
|
|
12231
12453
|
|
|
12232
12454
|
|
|
12455
|
+
# ---------------------------------------------------------------------------
|
|
12456
|
+
# Agent Mail (AgentMail) — agent email inbox via the ClawPump MCP
|
|
12457
|
+
# ---------------------------------------------------------------------------
|
|
12458
|
+
# Thin proxies over the ``agent_mail_*`` MCP tools, mirroring the x402 route
|
|
12459
|
+
# above. The two spending/outward actions (provision an inbox, send an email)
|
|
12460
|
+
# require an explicit ``confirm`` from the UI — the dashboard never auto-spends.
|
|
12461
|
+
|
|
12462
|
+
|
|
12463
|
+
def _agent_args(agent_id: Optional[str]) -> Dict[str, Any]:
|
|
12464
|
+
"""Base args for a ClawPump MCP call. Omit ``agent_id`` entirely when the
|
|
12465
|
+
dashboard hasn't picked one so the MCP resolves the default/only agent."""
|
|
12466
|
+
aid = (agent_id or "").strip()
|
|
12467
|
+
return {"agent_id": aid} if aid else {}
|
|
12468
|
+
|
|
12469
|
+
|
|
12470
|
+
def _clawpump_call_once(tool: str, arguments: Optional[dict] = None, *, timeout: float = 30) -> Any:
|
|
12471
|
+
"""Call a ClawPump MCP tool over a fresh, one-shot connection.
|
|
12472
|
+
|
|
12473
|
+
For rare, irreversible actions (wallet transfers, email sends, paid inbox
|
|
12474
|
+
provisioning): a fresh connect means current auth and — crucially — exactly
|
|
12475
|
+
one attempt, so a dropped response is never silently re-sent. Frequent reads
|
|
12476
|
+
use the warm ``_clawpump_call`` instead.
|
|
12477
|
+
"""
|
|
12478
|
+
from hermes_cli.mcp_config import _call_single_tool
|
|
12479
|
+
|
|
12480
|
+
name, cfg = _clawpump_mcp()
|
|
12481
|
+
if not name:
|
|
12482
|
+
raise RuntimeError("ClawPump MCP is not configured. Run `hermes clawpump setup`.")
|
|
12483
|
+
data = _parse_mcp_json(_call_single_tool(name, cfg, tool, arguments or {}, connect_timeout=timeout))
|
|
12484
|
+
if isinstance(data, dict) and isinstance(data.get("error"), str) and data["error"]:
|
|
12485
|
+
raise RuntimeError(data["error"])
|
|
12486
|
+
return data
|
|
12487
|
+
|
|
12488
|
+
|
|
12489
|
+
class MailCreateBody(BaseModel):
|
|
12490
|
+
agent_id: Optional[str] = None
|
|
12491
|
+
username: Optional[str] = None
|
|
12492
|
+
confirm: bool = False
|
|
12493
|
+
|
|
12494
|
+
|
|
12495
|
+
class MailSendBody(BaseModel):
|
|
12496
|
+
agent_id: Optional[str] = None
|
|
12497
|
+
to: Any = None # str | list[str] — the MCP tool accepts either
|
|
12498
|
+
subject: str
|
|
12499
|
+
text: Optional[str] = None
|
|
12500
|
+
html: Optional[str] = None
|
|
12501
|
+
cc: Optional[List[str]] = None
|
|
12502
|
+
bcc: Optional[List[str]] = None
|
|
12503
|
+
reply_to: Optional[str] = None
|
|
12504
|
+
confirm: bool = False
|
|
12505
|
+
|
|
12506
|
+
|
|
12507
|
+
@app.get("/api/mail/address")
|
|
12508
|
+
def mail_address(agent_id: str = ""):
|
|
12509
|
+
"""The agent's email address + inbox status via ``agent_mail_get_address``."""
|
|
12510
|
+
try:
|
|
12511
|
+
data = _clawpump_call("agent_mail_get_address", _agent_args(agent_id))
|
|
12512
|
+
except Exception as exc:
|
|
12513
|
+
return {"ok": False, "error": str(exc), "has_inbox": False, "inbox": None}
|
|
12514
|
+
inbox = data.get("inbox") if isinstance(data, dict) else None
|
|
12515
|
+
return {"ok": True, "has_inbox": bool(inbox), "inbox": inbox}
|
|
12516
|
+
|
|
12517
|
+
|
|
12518
|
+
@app.get("/api/mail/messages")
|
|
12519
|
+
def mail_messages(agent_id: str = "", direction: str = "", limit: int = 50):
|
|
12520
|
+
"""List the agent's emails (synced inbound + sent) via ``agent_mail_list``."""
|
|
12521
|
+
args = _agent_args(agent_id)
|
|
12522
|
+
if direction in ("inbound", "outbound"):
|
|
12523
|
+
args["direction"] = direction
|
|
12524
|
+
args["limit"] = max(1, min(int(limit or 50), 200))
|
|
12525
|
+
try:
|
|
12526
|
+
data = _clawpump_call("agent_mail_list", args)
|
|
12527
|
+
except Exception as exc:
|
|
12528
|
+
return {"ok": False, "error": str(exc), "messages": []}
|
|
12529
|
+
messages = data.get("messages") if isinstance(data, dict) else None
|
|
12530
|
+
return {"ok": True, "messages": messages if isinstance(messages, list) else []}
|
|
12531
|
+
|
|
12532
|
+
|
|
12533
|
+
@app.get("/api/mail/message")
|
|
12534
|
+
def mail_message(message_id: str, agent_id: str = ""):
|
|
12535
|
+
"""Read one email (full body) via ``agent_mail_read``."""
|
|
12536
|
+
args = _agent_args(agent_id)
|
|
12537
|
+
args["message_id"] = message_id
|
|
12538
|
+
try:
|
|
12539
|
+
data = _clawpump_call("agent_mail_read", args)
|
|
12540
|
+
except Exception as exc:
|
|
12541
|
+
return {"ok": False, "error": str(exc), "message": None}
|
|
12542
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
12543
|
+
return {"ok": True, "message": message}
|
|
12544
|
+
|
|
12545
|
+
|
|
12546
|
+
@app.post("/api/mail/create")
|
|
12547
|
+
def mail_create(body: MailCreateBody):
|
|
12548
|
+
"""Provision the agent's inbox (~$2 USDC) via ``agent_mail_create``."""
|
|
12549
|
+
if not body.confirm:
|
|
12550
|
+
return {
|
|
12551
|
+
"ok": False,
|
|
12552
|
+
"error": "Provisioning an inbox costs ~$2 USDC from the agent wallet — confirm to proceed.",
|
|
12553
|
+
}
|
|
12554
|
+
args = _agent_args(body.agent_id)
|
|
12555
|
+
args["confirm_provision"] = True
|
|
12556
|
+
if body.username and body.username.strip():
|
|
12557
|
+
args["username"] = body.username.strip()
|
|
12558
|
+
try:
|
|
12559
|
+
data = _clawpump_call_once("agent_mail_create", args)
|
|
12560
|
+
except Exception as exc:
|
|
12561
|
+
return {"ok": False, "error": str(exc)}
|
|
12562
|
+
if not isinstance(data, dict):
|
|
12563
|
+
data = {}
|
|
12564
|
+
return {
|
|
12565
|
+
"ok": True,
|
|
12566
|
+
"inbox": data.get("inbox"),
|
|
12567
|
+
"alreadyExisted": bool(data.get("alreadyExisted")),
|
|
12568
|
+
"note": data.get("note"),
|
|
12569
|
+
}
|
|
12570
|
+
|
|
12571
|
+
|
|
12572
|
+
@app.post("/api/mail/send")
|
|
12573
|
+
def mail_send(body: MailSendBody):
|
|
12574
|
+
"""Send an email from the agent's inbox via ``agent_mail_send`` (x402)."""
|
|
12575
|
+
if not body.confirm:
|
|
12576
|
+
return {"ok": False, "error": "Sending email is outward-facing — confirm to proceed."}
|
|
12577
|
+
if not (body.text and body.text.strip()) and not (body.html and body.html.strip()):
|
|
12578
|
+
return {"ok": False, "error": "Provide a message body."}
|
|
12579
|
+
if not body.to:
|
|
12580
|
+
return {"ok": False, "error": "At least one recipient is required."}
|
|
12581
|
+
args = _agent_args(body.agent_id)
|
|
12582
|
+
args["confirm_send"] = True
|
|
12583
|
+
args["to"] = body.to
|
|
12584
|
+
args["subject"] = body.subject
|
|
12585
|
+
if body.text:
|
|
12586
|
+
args["text"] = body.text
|
|
12587
|
+
if body.html:
|
|
12588
|
+
args["html"] = body.html
|
|
12589
|
+
if body.cc:
|
|
12590
|
+
args["cc"] = body.cc
|
|
12591
|
+
if body.bcc:
|
|
12592
|
+
args["bcc"] = body.bcc
|
|
12593
|
+
if body.reply_to and body.reply_to.strip():
|
|
12594
|
+
args["reply_to"] = body.reply_to.strip()
|
|
12595
|
+
try:
|
|
12596
|
+
data = _clawpump_call_once("agent_mail_send", args)
|
|
12597
|
+
except Exception as exc:
|
|
12598
|
+
return {"ok": False, "error": str(exc)}
|
|
12599
|
+
return {"ok": True, "result": data}
|
|
12600
|
+
|
|
12601
|
+
|
|
12233
12602
|
_FONT_DEFAULT_ID = "space-grotesk"
|
|
12234
12603
|
_FONT_CHOICES = frozenset({
|
|
12235
12604
|
"system-sans", "system-serif", "system-mono",
|
|
@@ -39,13 +39,19 @@ USEPOD_API_BASE = "https://api.usepod.ai"
|
|
|
39
39
|
class UsePodProfile(ProviderProfile):
|
|
40
40
|
"""UsePod — OpenAI-compatible proxy with the auth token in the URL path."""
|
|
41
41
|
|
|
42
|
-
def fetch_models(self, *, api_key: str | None = None, timeout: float = 8.0):
|
|
42
|
+
def fetch_models(self, *, api_key: str | None = None, base_url: str | None = None, timeout: float = 8.0):
|
|
43
43
|
"""List models from the per-token catalog endpoint.
|
|
44
44
|
|
|
45
45
|
UsePod exposes ``/proxy/<token>/v1/models``; the token lives in the
|
|
46
46
|
path, so the base-class implementation (which builds the URL from the
|
|
47
47
|
static ``base_url``) cannot reach it. Returns None when no token is
|
|
48
48
|
available so callers fall back to ``fallback_models``.
|
|
49
|
+
|
|
50
|
+
``base_url`` is accepted (and ignored — the URL is derived from the
|
|
51
|
+
token) only so the generic picker fetch in ``models.provider_model_ids``
|
|
52
|
+
— which calls ``fetch_models(api_key=…, base_url=…)`` for every api-key
|
|
53
|
+
provider — doesn't raise a TypeError. That TypeError was silently
|
|
54
|
+
swallowed, leaving UsePod with zero models in the picker. (#pod-models)
|
|
49
55
|
"""
|
|
50
56
|
token = (api_key or "").strip()
|
|
51
57
|
if not token:
|
package/agent/scripts/release.py
CHANGED
|
@@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
|
|
46
46
|
# Auto-extracted from noreply emails + manual overrides
|
|
47
47
|
AUTHOR_MAP = {
|
|
48
48
|
"chris@100x.dev": "chris-gilbert",
|
|
49
|
+
"0xtomi204@gmail.com": "tomi204", # ClawPump maintainer (Tomas Oliver)
|
|
49
50
|
"charles@salesondemand.io": "salesondemandio",
|
|
50
51
|
"victor@rocketfueldev.com": "victor-kyriazakos",
|
|
51
52
|
"87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44",
|
|
Binary file
|
package/agent/web/src/App.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
Globe,
|
|
34
34
|
Heart,
|
|
35
35
|
KeyRound,
|
|
36
|
+
Mail,
|
|
36
37
|
Menu,
|
|
37
38
|
MessageSquare,
|
|
38
39
|
Package,
|
|
@@ -76,6 +77,7 @@ import type { SystemAction } from "@/contexts/system-actions-context";
|
|
|
76
77
|
import ConfigPage from "@/pages/ConfigPage";
|
|
77
78
|
import WalletPage from "@/pages/WalletPage";
|
|
78
79
|
import X402Page from "@/pages/X402Page";
|
|
80
|
+
import AgentMailPage from "@/pages/AgentMailPage";
|
|
79
81
|
import DocsPage from "@/pages/DocsPage";
|
|
80
82
|
import EnvPage from "@/pages/EnvPage";
|
|
81
83
|
import FilesPage from "@/pages/FilesPage";
|
|
@@ -137,6 +139,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|
|
137
139
|
"/": RootRedirect,
|
|
138
140
|
"/wallet": WalletPage,
|
|
139
141
|
"/x402": X402Page,
|
|
142
|
+
"/mail": AgentMailPage,
|
|
140
143
|
"/sessions": SessionsPage,
|
|
141
144
|
"/files": FilesPage,
|
|
142
145
|
"/analytics": AnalyticsPage,
|
|
@@ -168,6 +171,7 @@ function ChatRouteSink() {
|
|
|
168
171
|
const BUILTIN_NAV_REST: NavItem[] = [
|
|
169
172
|
{ path: "/wallet", label: "Wallet", icon: Wallet },
|
|
170
173
|
{ path: "/x402", label: "x402", icon: Zap },
|
|
174
|
+
{ path: "/mail", label: "Mail", icon: Mail },
|
|
171
175
|
{
|
|
172
176
|
path: "/sessions",
|
|
173
177
|
labelKey: "sessions",
|
|
@@ -522,10 +526,9 @@ export default function App() {
|
|
|
522
526
|
</Button>
|
|
523
527
|
|
|
524
528
|
<img
|
|
525
|
-
src="/claw-logo.
|
|
529
|
+
src="/claw-logo.png"
|
|
526
530
|
alt=""
|
|
527
531
|
className="h-7 w-7 shrink-0"
|
|
528
|
-
style={{ mixBlendMode: "screen" }}
|
|
529
532
|
/>
|
|
530
533
|
<Typography
|
|
531
534
|
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
|
|
@@ -587,10 +590,9 @@ export default function App() {
|
|
|
587
590
|
<PluginSlot name="header-left" />
|
|
588
591
|
|
|
589
592
|
<img
|
|
590
|
-
src="/claw-logo.
|
|
593
|
+
src="/claw-logo.png"
|
|
591
594
|
alt="Claw Agent"
|
|
592
595
|
className="h-8 w-8 shrink-0"
|
|
593
|
-
style={{ mixBlendMode: "screen" }}
|
|
594
596
|
/>
|
|
595
597
|
<Typography
|
|
596
598
|
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
|
|
@@ -29,6 +29,7 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
|
|
|
29
29
|
import { Card } from "@nous-research/ui/ui/components/card";
|
|
30
30
|
|
|
31
31
|
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
|
32
|
+
import PodCredits from "@/components/PodCredits";
|
|
32
33
|
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
|
|
33
34
|
import { ReasoningPicker } from "@/components/ReasoningPicker";
|
|
34
35
|
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
|
@@ -121,6 +122,7 @@ export function ChatSidebar({
|
|
|
121
122
|
// elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped
|
|
122
123
|
// by `fetchJSON`, so it reads the same profile this sidebar is scoped to.
|
|
123
124
|
const [effectiveModel, setEffectiveModel] = useState("");
|
|
125
|
+
const [effectiveProvider, setEffectiveProvider] = useState("");
|
|
124
126
|
// Whether the effective model supports reasoning effort — gates the
|
|
125
127
|
// ReasoningPicker. Read from the same `/api/model/info` capabilities the
|
|
126
128
|
// (currently unused) ModelInfoCard surfaces, so the dashboard exposes a
|
|
@@ -143,6 +145,7 @@ export function ChatSidebar({
|
|
|
143
145
|
.getModelInfo()
|
|
144
146
|
.then((r) => {
|
|
145
147
|
if (r?.model) setEffectiveModel(String(r.model));
|
|
148
|
+
setEffectiveProvider(r?.provider ? String(r.provider) : "");
|
|
146
149
|
setSupportsReasoning(!!r?.capabilities?.supports_reasoning);
|
|
147
150
|
// Bump so ReasoningPicker re-reads the saved effort for the new model.
|
|
148
151
|
setModelRefreshKey((k) => k + 1);
|
|
@@ -449,6 +452,8 @@ export function ChatSidebar({
|
|
|
449
452
|
</span>
|
|
450
453
|
</Button>
|
|
451
454
|
|
|
455
|
+
<PodCredits provider={effectiveProvider} />
|
|
456
|
+
|
|
452
457
|
<Badge tone={STATE_TONE[state]} className="hidden shrink-0 md:inline-flex">
|
|
453
458
|
{STATE_LABEL[state]}
|
|
454
459
|
</Badge>
|
|
@@ -5,8 +5,9 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
|
5
5
|
import { Input } from "@nous-research/ui/ui/components/input";
|
|
6
6
|
import { Label } from "@nous-research/ui/ui/components/label";
|
|
7
7
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
8
|
+
import PodSetupDialog from "@/components/PodSetupDialog";
|
|
8
9
|
import type { GatewayClient } from "@/lib/gatewayClient";
|
|
9
|
-
import { Check, Search, X } from "lucide-react";
|
|
10
|
+
import { Check, Search, X, Zap } from "lucide-react";
|
|
10
11
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
11
12
|
import { createPortal } from "react-dom";
|
|
12
13
|
import { cn, themedBody } from "@/lib/utils";
|
|
@@ -110,6 +111,7 @@ export function ModelPickerDialog(props: Props) {
|
|
|
110
111
|
const [selectedModel, setSelectedModel] = useState("");
|
|
111
112
|
const [query, setQuery] = useState("");
|
|
112
113
|
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
|
|
114
|
+
const [podOpen, setPodOpen] = useState(false);
|
|
113
115
|
const [applying, setApplying] = useState(false);
|
|
114
116
|
const [pendingConfirm, setPendingConfirm] =
|
|
115
117
|
useState<PendingExpensiveConfirm | null>(null);
|
|
@@ -366,6 +368,13 @@ export function ModelPickerDialog(props: Props) {
|
|
|
366
368
|
</div>
|
|
367
369
|
|
|
368
370
|
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
|
|
371
|
+
<Button
|
|
372
|
+
outlined
|
|
373
|
+
onClick={() => setPodOpen(true)}
|
|
374
|
+
className="gap-1.5"
|
|
375
|
+
>
|
|
376
|
+
<Zap className="h-3.5 w-3.5 text-primary" /> Set up Pod
|
|
377
|
+
</Button>
|
|
369
378
|
{alwaysGlobal ? (
|
|
370
379
|
<span className="text-xs text-muted-foreground">
|
|
371
380
|
Saves to config.yaml — applies to new sessions.
|
|
@@ -415,6 +424,24 @@ export function ModelPickerDialog(props: Props) {
|
|
|
415
424
|
void applySelection(true, pending);
|
|
416
425
|
}}
|
|
417
426
|
/>
|
|
427
|
+
{podOpen && (
|
|
428
|
+
<PodSetupDialog
|
|
429
|
+
onClose={() => setPodOpen(false)}
|
|
430
|
+
onProvisioned={(model) => {
|
|
431
|
+
// Called from the dialog's "Pod ready" → Done. Backend already set
|
|
432
|
+
// provider=usepod + model in config; switch the live session too
|
|
433
|
+
// (slash in chat mode / onApply in standalone). applySelection calls
|
|
434
|
+
// onClose() → that unmounts this dialog and the whole picker.
|
|
435
|
+
setPodOpen(false);
|
|
436
|
+
void applySelection(false, {
|
|
437
|
+
message: "",
|
|
438
|
+
model,
|
|
439
|
+
persistGlobal: true,
|
|
440
|
+
provider: "usepod",
|
|
441
|
+
});
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
)}
|
|
418
445
|
</div>,
|
|
419
446
|
document.body,
|
|
420
447
|
);
|