@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.
Files changed (64) hide show
  1. package/agent/.mailmap +4 -0
  2. package/agent/apps/desktop/README.md +3 -3
  3. package/agent/apps/desktop/assets/icon.icns +0 -0
  4. package/agent/apps/desktop/assets/icon.ico +0 -0
  5. package/agent/apps/desktop/assets/icon.png +0 -0
  6. package/agent/apps/desktop/electron/backend-ready.cjs +2 -2
  7. package/agent/apps/desktop/electron/dashboard-token.cjs +3 -3
  8. package/agent/apps/desktop/electron/hardening.cjs +1 -1
  9. package/agent/apps/desktop/electron/main.cjs +65 -65
  10. package/agent/apps/desktop/index.html +1 -1
  11. package/agent/apps/desktop/package.json +11 -11
  12. package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
  13. package/agent/apps/desktop/public/claw-mark.png +0 -0
  14. package/agent/apps/desktop/scripts/set-exe-identity.cjs +2 -2
  15. package/agent/apps/desktop/src/app/chat/composer/controls.tsx +2 -0
  16. package/agent/apps/desktop/src/app/chat/composer/index.tsx +10 -0
  17. package/agent/apps/desktop/src/app/chat/composer/pod-credits.tsx +49 -0
  18. package/agent/apps/desktop/src/app/chat/index.tsx +1 -1
  19. package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +4 -2
  20. package/agent/apps/desktop/src/app/desktop-controller.tsx +18 -0
  21. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +1 -1
  22. package/agent/apps/desktop/src/app/messaging/index.tsx +5 -5
  23. package/agent/apps/desktop/src/app/routes.ts +9 -1
  24. package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +3 -3
  25. package/agent/apps/desktop/src/app/settings/constants.ts +5 -5
  26. package/agent/apps/desktop/src/app/settings/model-settings.tsx +1 -1
  27. package/agent/apps/desktop/src/app/settings/providers-settings.tsx +46 -1
  28. package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +5 -5
  29. package/agent/apps/desktop/src/app/types.ts +9 -1
  30. package/agent/apps/desktop/src/app/wallet/index.tsx +244 -0
  31. package/agent/apps/desktop/src/app/x402/index.tsx +162 -0
  32. package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +1 -1
  33. package/agent/apps/desktop/src/components/brand-mark.tsx +2 -2
  34. package/agent/apps/desktop/src/components/chat/intro-copy.jsonl +6 -6
  35. package/agent/apps/desktop/src/components/chat/intro.tsx +4 -4
  36. package/agent/apps/desktop/src/components/model-picker.tsx +64 -4
  37. package/agent/apps/desktop/src/components/pod-setup-dialog.tsx +227 -0
  38. package/agent/apps/desktop/src/hermes.ts +109 -3
  39. package/agent/apps/desktop/src/i18n/en.ts +80 -78
  40. package/agent/apps/desktop/src/i18n/ja.ts +82 -82
  41. package/agent/apps/desktop/src/i18n/runtime.test.ts +2 -2
  42. package/agent/apps/desktop/src/i18n/zh-hant.ts +82 -82
  43. package/agent/apps/desktop/src/i18n/zh.ts +87 -87
  44. package/agent/apps/desktop/src/lib/desktop-fs.ts +1 -1
  45. package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +4 -4
  46. package/agent/apps/desktop/src/store/composer.ts +7 -0
  47. package/agent/apps/desktop/src/store/onboarding.ts +5 -5
  48. package/agent/apps/desktop/src/themes/presets.ts +54 -54
  49. package/agent/cli.py +184 -10
  50. package/agent/hermes_cli/distribution.py +188 -8
  51. package/agent/hermes_cli/providers.py +29 -0
  52. package/agent/hermes_cli/web_server.py +403 -34
  53. package/agent/plugins/model-providers/usepod/__init__.py +7 -1
  54. package/agent/scripts/release.py +1 -0
  55. package/agent/web/public/claw-logo.png +0 -0
  56. package/agent/web/src/App.tsx +6 -4
  57. package/agent/web/src/components/ChatSidebar.tsx +5 -0
  58. package/agent/web/src/components/ModelPickerDialog.tsx +28 -1
  59. package/agent/web/src/components/PodCredits.tsx +57 -0
  60. package/agent/web/src/components/PodSetupDialog.tsx +240 -0
  61. package/agent/web/src/lib/api.ts +135 -0
  62. package/agent/web/src/pages/AgentMailPage.tsx +684 -0
  63. package/agent/web/src/pages/WalletPage.tsx +53 -5
  64. 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
- a short-lived MCP session (OAuth/API-key from ``hermes clawpump setup``).
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
- text = _call_single_tool(srv_name, srv_cfg, "get_wallet_summaries", {})
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 = _parse_mcp_json(_call_single_tool(srv_name, srv_cfg, "list_agents", {}))
12181
+ agents = _clawpump_call("list_agents", {})
12114
12182
  if isinstance(agents, dict):
12115
12183
  agents = agents.get("agents")
12116
- names = {
12117
- a["id"]: a.get("name")
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
- w["name"] = names.get(w.get("agent_id"))
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
- text = _call_single_tool(
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:
@@ -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
@@ -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.webp"
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.webp"
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
  );