@clawpump/claw-agent 0.1.8 → 0.1.11

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 (65) 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 +67 -67
  10. package/agent/apps/desktop/index.html +1 -1
  11. package/agent/apps/desktop/package.json +16 -16
  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/after-pack.cjs +1 -1
  15. package/agent/apps/desktop/scripts/set-exe-identity.cjs +2 -2
  16. package/agent/apps/desktop/scripts/test-desktop.mjs +3 -3
  17. package/agent/apps/desktop/src/app/chat/composer/controls.tsx +2 -0
  18. package/agent/apps/desktop/src/app/chat/composer/index.tsx +10 -0
  19. package/agent/apps/desktop/src/app/chat/composer/pod-credits.tsx +49 -0
  20. package/agent/apps/desktop/src/app/chat/index.tsx +1 -1
  21. package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +4 -2
  22. package/agent/apps/desktop/src/app/desktop-controller.tsx +18 -0
  23. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +1 -1
  24. package/agent/apps/desktop/src/app/messaging/index.tsx +5 -5
  25. package/agent/apps/desktop/src/app/routes.ts +9 -1
  26. package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +3 -3
  27. package/agent/apps/desktop/src/app/settings/constants.ts +5 -5
  28. package/agent/apps/desktop/src/app/settings/model-settings.tsx +1 -1
  29. package/agent/apps/desktop/src/app/settings/providers-settings.tsx +46 -1
  30. package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +5 -5
  31. package/agent/apps/desktop/src/app/types.ts +9 -1
  32. package/agent/apps/desktop/src/app/wallet/index.tsx +244 -0
  33. package/agent/apps/desktop/src/app/x402/index.tsx +162 -0
  34. package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +1 -1
  35. package/agent/apps/desktop/src/components/brand-mark.tsx +2 -2
  36. package/agent/apps/desktop/src/components/chat/intro-copy.jsonl +6 -6
  37. package/agent/apps/desktop/src/components/chat/intro.tsx +4 -4
  38. package/agent/apps/desktop/src/components/model-picker.tsx +64 -4
  39. package/agent/apps/desktop/src/components/pod-setup-dialog.tsx +227 -0
  40. package/agent/apps/desktop/src/hermes.ts +109 -3
  41. package/agent/apps/desktop/src/i18n/en.ts +80 -78
  42. package/agent/apps/desktop/src/i18n/ja.ts +82 -82
  43. package/agent/apps/desktop/src/i18n/runtime.test.ts +2 -2
  44. package/agent/apps/desktop/src/i18n/zh-hant.ts +82 -82
  45. package/agent/apps/desktop/src/i18n/zh.ts +87 -87
  46. package/agent/apps/desktop/src/lib/desktop-fs.ts +1 -1
  47. package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +4 -4
  48. package/agent/apps/desktop/src/store/composer.ts +7 -0
  49. package/agent/apps/desktop/src/store/onboarding.ts +5 -5
  50. package/agent/apps/desktop/src/themes/presets.ts +54 -54
  51. package/agent/cli.py +184 -10
  52. package/agent/hermes_cli/distribution.py +188 -8
  53. package/agent/hermes_cli/gui_uninstall.py +11 -6
  54. package/agent/hermes_cli/main.py +9 -4
  55. package/agent/hermes_cli/providers.py +29 -0
  56. package/agent/hermes_cli/web_server.py +180 -2
  57. package/agent/plugins/model-providers/usepod/__init__.py +7 -1
  58. package/agent/scripts/install.sh +3 -1
  59. package/agent/scripts/release.py +1 -0
  60. package/agent/web/src/components/ChatSidebar.tsx +5 -0
  61. package/agent/web/src/components/ModelPickerDialog.tsx +28 -1
  62. package/agent/web/src/components/PodCredits.tsx +57 -0
  63. package/agent/web/src/components/PodSetupDialog.tsx +240 -0
  64. package/agent/web/src/lib/api.ts +23 -0
  65. package/package.json +1 -1
@@ -70,21 +70,21 @@ def _agent_root(hermes_home: Path) -> Path:
70
70
  def desktop_userdata_dir() -> Path:
71
71
  """Return the Electron ``userData`` directory for the desktop app.
72
72
 
73
- Mirrors Electron's ``app.getPath('userData')`` for an app named "Hermes"
73
+ Mirrors Electron's ``app.getPath('userData')`` for an app named "Claw Agent"
74
74
  on each platform. This is GUI-only state (connection.json, updates.json,
75
75
  Chromium cache) and never holds agent config or sessions.
76
76
  """
77
77
  home = Path.home()
78
78
  if sys.platform == "darwin":
79
- return home / "Library" / "Application Support" / "Hermes"
79
+ return home / "Library" / "Application Support" / "Claw Agent"
80
80
  if sys.platform == "win32":
81
81
  appdata = os.environ.get("APPDATA")
82
82
  base = Path(appdata) if appdata else (home / "AppData" / "Roaming")
83
- return base / "Hermes"
83
+ return base / "Claw Agent"
84
84
  # Linux / other POSIX — XDG config home.
85
85
  xdg = os.environ.get("XDG_CONFIG_HOME")
86
86
  base = Path(xdg) if xdg else (home / ".config")
87
- return base / "Hermes"
87
+ return base / "Claw Agent"
88
88
 
89
89
 
90
90
  def source_built_gui_artifacts(hermes_home: Path) -> "list[Path]":
@@ -119,6 +119,9 @@ def packaged_gui_app_paths() -> "list[Path]":
119
119
  paths: list[Path] = []
120
120
  if sys.platform == "darwin":
121
121
  paths += [
122
+ Path("/Applications/Claw Agent.app"),
123
+ home / "Applications" / "Claw Agent.app",
124
+ # Legacy bundle name (older local builds) — clean up if present.
122
125
  Path("/Applications/Hermes.app"),
123
126
  home / "Applications" / "Hermes.app",
124
127
  ]
@@ -126,14 +129,16 @@ def packaged_gui_app_paths() -> "list[Path]":
126
129
  local = os.environ.get("LOCALAPPDATA")
127
130
  local_base = Path(local) if local else (home / "AppData" / "Local")
128
131
  paths += [
129
- # NSIS per-user install (perMachine=false → Programs\Hermes).
130
- local_base / "Programs" / "Hermes",
132
+ # NSIS per-user install (perMachine=false → Programs\Claw Agent).
133
+ local_base / "Programs" / "Claw Agent",
134
+ local_base / "Programs" / "Hermes", # legacy
131
135
  # Older / alternate layout some builds used.
132
136
  local_base / "hermes-desktop",
133
137
  ]
134
138
  program_files = os.environ.get("ProgramFiles")
135
139
  if program_files:
136
140
  # NSIS per-machine fallback (needs admin to remove).
141
+ paths.append(Path(program_files) / "Claw Agent")
137
142
  paths.append(Path(program_files) / "Hermes")
138
143
  else:
139
144
  # Linux: AppImage is a single file the user placed somewhere; we can
@@ -5003,19 +5003,24 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
5003
5003
  """Return the current platform's unpacked Electron app executable."""
5004
5004
  release_dir = desktop_dir / "release"
5005
5005
  if sys.platform == "darwin":
5006
- candidates = list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes"))
5006
+ # Renamed bundle "Claw Agent.app" (binary MacOS/Claw Agent); keep the
5007
+ # legacy "Hermes.app" as a fallback for older local builds.
5008
+ candidates = list(release_dir.glob("mac*/Claw Agent.app/Contents/MacOS/Claw Agent"))
5009
+ candidates += list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes"))
5007
5010
  elif sys.platform == "win32":
5008
5011
  candidates = [
5012
+ release_dir / "win-unpacked" / "Claw Agent.exe",
5013
+ release_dir / "win-ia32-unpacked" / "Claw Agent.exe",
5014
+ release_dir / "win-arm64-unpacked" / "Claw Agent.exe",
5009
5015
  release_dir / "win-unpacked" / "Hermes.exe",
5010
- release_dir / "win-ia32-unpacked" / "Hermes.exe",
5011
- release_dir / "win-arm64-unpacked" / "Hermes.exe",
5012
5016
  ]
5013
5017
  else:
5014
5018
  candidates = [
5019
+ release_dir / "linux-unpacked" / "claw-agent",
5015
5020
  release_dir / "linux-unpacked" / "hermes",
5016
5021
  release_dir / "linux-unpacked" / "Hermes",
5022
+ release_dir / "linux-arm64-unpacked" / "claw-agent",
5017
5023
  release_dir / "linux-arm64-unpacked" / "hermes",
5018
- release_dir / "linux-arm64-unpacked" / "Hermes",
5019
5024
  ]
5020
5025
 
5021
5026
  existing = [p for p in candidates if p.exists()]
@@ -731,4 +731,33 @@ def resolve_provider_full(
731
731
  except Exception:
732
732
  pass
733
733
 
734
+ # 4. Plugin model providers (providers/ registry, e.g. usepod). Not in
735
+ # models.dev or the built-in alias table, so synthesize a ProviderDef
736
+ # from the plugin's ProviderProfile. Without this the live model-switch
737
+ # path ("Unknown provider 'usepod'") rejects any plugin api-key provider
738
+ # even though it's fully configured and serving models.
739
+ try:
740
+ from providers import get_provider_profile
741
+
742
+ prof = get_provider_profile(canonical) or get_provider_profile(raw)
743
+ if prof is not None:
744
+ env_vars = tuple(getattr(prof, "env_vars", ()) or ())
745
+ # First env var is the API key; a trailing *_BASE_URL is the
746
+ # optional custom-endpoint override.
747
+ key_envs = tuple(e for e in env_vars if not e.upper().endswith("_BASE_URL")) or env_vars[:1]
748
+ base_env = next((e for e in env_vars if e.upper().endswith("_BASE_URL")), "")
749
+ transport = "anthropic_messages" if getattr(prof, "api_mode", "") == "anthropic_messages" else "openai_chat"
750
+ return ProviderDef(
751
+ id=canonical,
752
+ name=getattr(prof, "display_name", "") or getattr(prof, "name", canonical),
753
+ transport=transport,
754
+ api_key_env_vars=key_envs,
755
+ base_url=getattr(prof, "base_url", "") or "",
756
+ base_url_env_var=base_env,
757
+ auth_type=getattr(prof, "auth_type", "api_key") or "api_key",
758
+ source="plugin",
759
+ )
760
+ except Exception:
761
+ pass
762
+
734
763
  return None
@@ -12097,13 +12097,21 @@ class _McpToolError(RuntimeError):
12097
12097
  """The tool ran but returned an error — the session itself is healthy."""
12098
12098
 
12099
12099
 
12100
- def _clawpump_call(tool: str, arguments: Optional[dict] = None, *, timeout: float = 20) -> Any:
12100
+ def _clawpump_call(
12101
+ tool: str, arguments: Optional[dict] = None, *, timeout: float = 20, prefer_structured: bool = False
12102
+ ) -> Any:
12101
12103
  """Call a ClawPump MCP tool over the dashboard's warm, reused session.
12102
12104
 
12103
12105
  Reuses one long-lived connection and reconnects only if the session has
12104
12106
  died, so the dashboard behaves like the agent's persistent connection
12105
12107
  instead of reconnecting (and stalling) per request. Returns parsed JSON;
12106
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.
12107
12115
  """
12108
12116
  global _warm_mcp_server
12109
12117
 
@@ -12123,9 +12131,14 @@ def _clawpump_call(tool: str, arguments: Optional[dict] = None, *, timeout: floa
12123
12131
  text = "".join(b.text for b in (result.content or []) if hasattr(b, "text"))
12124
12132
  if getattr(result, "isError", False):
12125
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
12126
12138
  return text
12127
12139
 
12128
- data = _parse_mcp_json(_run_on_mcp_loop(_coro, timeout=timeout))
12140
+ raw = _run_on_mcp_loop(_coro, timeout=timeout)
12141
+ data = raw if isinstance(raw, dict) else _parse_mcp_json(raw)
12129
12142
  if isinstance(data, dict) and isinstance(data.get("error"), str) and data["error"]:
12130
12143
  raise _McpToolError(data["error"])
12131
12144
  return data
@@ -12186,6 +12199,171 @@ def get_wallet_balances():
12186
12199
  return {"ok": True, "wallets": wallets}
12187
12200
 
12188
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
+
12189
12367
  class WalletTransferBody(BaseModel):
12190
12368
  agent_id: str
12191
12369
  to: str
@@ -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:
@@ -172,7 +172,7 @@ while [[ $# -gt 0 ]]; do
172
172
  echo " --stage NAME Run one desktop bootstrap stage"
173
173
  echo " --json Print a JSON result frame for --stage"
174
174
  echo " --non-interactive Skip stages that require user input"
175
- echo " --include-desktop Also build the desktop app (apps/desktop -> Hermes.app)"
175
+ echo " --include-desktop Also build the desktop app (apps/desktop -> Claw Agent.app)"
176
176
  echo " --dir PATH Installation directory"
177
177
  echo " default (non-root): ~/.hermes/hermes-agent"
178
178
  echo " default (root, Linux): /usr/local/lib/hermes-agent"
@@ -2579,6 +2579,8 @@ install_desktop() {
2579
2579
  else
2580
2580
  local cand
2581
2581
  for cand in \
2582
+ "$desktop_dir/release/mac-arm64/Claw Agent.app" \
2583
+ "$desktop_dir/release/mac/Claw Agent.app" \
2582
2584
  "$desktop_dir/release/mac-arm64/Hermes.app" \
2583
2585
  "$desktop_dir/release/mac/Hermes.app"; do
2584
2586
  if [ -d "$cand" ]; then
@@ -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",
@@ -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
  );
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { api } from "@/lib/api";
3
+
4
+ /**
5
+ * Compact "Pod $X.XX" credits pill, shown next to the model badge only when the
6
+ * active provider is UsePod (Pod). Polls /api/clawpump/pod/status; keeps the
7
+ * last value so a transient probe failure never blanks it. Mirrors the desktop
8
+ * PodCredits.
9
+ */
10
+ export default function PodCredits({ provider }: { provider: string }) {
11
+ const isPod = provider === "usepod";
12
+ const [balance, setBalance] = useState<number | null>(null);
13
+ const [connected, setConnected] = useState(false);
14
+ const lastBalance = useRef<number | null>(null);
15
+
16
+ useEffect(() => {
17
+ if (!isPod) return;
18
+ let cancelled = false;
19
+ const tick = () => {
20
+ void api
21
+ .getPodStatus()
22
+ .then((r) => {
23
+ if (cancelled) return;
24
+ setConnected(!!r.connected);
25
+ if (r.balance_usdc != null) {
26
+ lastBalance.current = r.balance_usdc;
27
+ setBalance(r.balance_usdc);
28
+ } else if (lastBalance.current != null) {
29
+ setBalance(lastBalance.current);
30
+ }
31
+ })
32
+ .catch(() => undefined);
33
+ };
34
+ tick();
35
+ const id = window.setInterval(tick, 60_000);
36
+ return () => {
37
+ cancelled = true;
38
+ window.clearInterval(id);
39
+ };
40
+ }, [isPod]);
41
+
42
+ if (!isPod || !connected) return null;
43
+
44
+ const low = balance != null && balance < 0.5;
45
+ return (
46
+ <span
47
+ title={balance != null ? `Pod credits: $${balance.toFixed(4)} USDC` : "Pod connected"}
48
+ className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[0.6875rem] font-medium ${
49
+ low
50
+ ? "border-amber-500/40 bg-amber-500/10 text-amber-300"
51
+ : "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"
52
+ }`}
53
+ >
54
+ ⚡ {balance != null ? `$${balance.toFixed(2)}` : "Pod"}
55
+ </span>
56
+ );
57
+ }