@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
@@ -189,7 +189,7 @@ async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessRes
189
189
  }
190
190
 
191
191
  function notifyReady(provider: string) {
192
- notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
192
+ notify({ kind: 'success', title: 'Claw Agent is ready', message: `${provider} connected.` })
193
193
  }
194
194
 
195
195
  // Human-friendly labels for tools auto-routed through the Nous Tool Gateway,
@@ -360,8 +360,8 @@ function providerResolutionFailure(reason: null | string) {
360
360
  const detail = reason?.trim()
361
361
 
362
362
  return detail
363
- ? `Connected, but Hermes still cannot resolve a usable provider. ${detail}`
364
- : 'Connected, but Hermes still cannot resolve a usable provider.'
363
+ ? `Connected, but Claw Agent still cannot resolve a usable provider. ${detail}`
364
+ : 'Connected, but Claw Agent still cannot resolve a usable provider.'
365
365
  }
366
366
 
367
367
  async function refreshProviders() {
@@ -722,7 +722,7 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
722
722
  provider,
723
723
  message:
724
724
  reason?.trim() ||
725
- `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
725
+ `Claw Agent still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
726
726
  })
727
727
  )
728
728
  }
@@ -837,7 +837,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: strin
837
837
  if (!runtime.ready) {
838
838
  const detail = (runtime.reason ?? '').trim()
839
839
 
840
- return { ok: false, message: detail || `Saved, but Hermes still cannot reach ${url}.` }
840
+ return { ok: false, message: detail || `Saved, but Claw Agent still cannot reach ${url}.` }
841
841
  }
842
842
 
843
843
  notifyReady('Local / custom endpoint')
@@ -21,75 +21,75 @@ const SYSTEM_MONO =
21
21
 
22
22
  export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = { fontSans: SYSTEM_SANS, fontMono: SYSTEM_MONO }
23
23
 
24
- const NOUS_BLUE = '#0053FD'
25
- const PSYCHE_BLUE = '#1540B1'
26
- const PSYCHE_WARM = '#FFE6CB'
24
+ const CLAW_GREEN = '#22C55E'
25
+ const CLAW_GREEN_LIGHT = '#4ADE80'
26
+ const CLAW_GREEN_DEEP = '#16A34A'
27
27
 
28
- const nousTint = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, #FFFFFF)`
29
- const nousTintTransparent = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, transparent)`
28
+ const clawTint = (pct: number) => `color-mix(in srgb, ${CLAW_GREEN} ${pct}%, #FFFFFF)`
29
+ const clawTintTransparent = (pct: number) => `color-mix(in srgb, ${CLAW_GREEN} ${pct}%, transparent)`
30
30
 
31
31
  /**
32
- * Nous — canonical Hermes desktop identity. The palette keeps the current
33
- * glass geometry neutral, then lets the old bb/gui blue and psyche cream
34
- * return as accent seeds.
32
+ * Claw Agent — canonical ClawPump desktop identity (built on Hermes by Nous
33
+ * Research). Solana-green accents on glass neutrals in light mode, deep
34
+ * ClawPump green in dark mode.
35
35
  */
36
36
  export const nousTheme: DesktopTheme = {
37
37
  name: 'nous',
38
- label: 'Nous',
39
- description: 'Glass neutrals with Nous blue accents',
38
+ label: 'Claw Agent',
39
+ description: 'ClawPump green on glass neutrals',
40
40
  colors: {
41
- background: '#F8FAFF',
42
- foreground: '#17171A',
41
+ background: '#F6FBF8',
42
+ foreground: '#13201A',
43
43
  card: '#FFFFFF',
44
- cardForeground: '#17171A',
45
- muted: nousTint(5),
46
- mutedForeground: '#666678',
44
+ cardForeground: '#13201A',
45
+ muted: clawTint(5),
46
+ mutedForeground: '#5B6B62',
47
47
  popover: '#FFFFFF',
48
- popoverForeground: '#17171A',
49
- primary: NOUS_BLUE,
50
- primaryForeground: '#FCFCFC',
51
- secondary: nousTint(7),
52
- secondaryForeground: '#242432',
53
- accent: nousTint(10),
54
- accentForeground: '#202030',
55
- border: nousTintTransparent(22),
56
- input: nousTintTransparent(30),
57
- ring: NOUS_BLUE,
58
- midground: NOUS_BLUE,
59
- composerRing: NOUS_BLUE,
48
+ popoverForeground: '#13201A',
49
+ primary: CLAW_GREEN,
50
+ primaryForeground: '#FCFFFD',
51
+ secondary: clawTint(7),
52
+ secondaryForeground: '#1E2A24',
53
+ accent: clawTint(10),
54
+ accentForeground: '#1B2620',
55
+ border: clawTintTransparent(22),
56
+ input: clawTintTransparent(30),
57
+ ring: CLAW_GREEN,
58
+ midground: CLAW_GREEN,
59
+ composerRing: CLAW_GREEN,
60
60
  destructive: '#C72E4D',
61
61
  destructiveForeground: '#FFFFFF',
62
- sidebarBackground: '#F3F7FF',
63
- sidebarBorder: nousTintTransparent(18),
64
- userBubble: nousTint(6),
65
- userBubbleBorder: nousTintTransparent(24)
62
+ sidebarBackground: '#F1FAF4',
63
+ sidebarBorder: clawTintTransparent(18),
64
+ userBubble: clawTint(6),
65
+ userBubbleBorder: clawTintTransparent(24)
66
66
  },
67
67
  darkColors: {
68
- background: '#0D2F86',
69
- foreground: PSYCHE_WARM,
70
- card: '#12378F',
71
- cardForeground: PSYCHE_WARM,
72
- muted: '#183F9A',
73
- mutedForeground: '#B5C7F3',
74
- popover: '#123A96',
75
- popoverForeground: PSYCHE_WARM,
76
- primary: PSYCHE_WARM,
77
- primaryForeground: '#0D2F86',
78
- secondary: '#1B45A4',
79
- secondaryForeground: '#E0E8FF',
80
- accent: PSYCHE_BLUE,
81
- accentForeground: '#F0F4FF',
82
- border: '#3158AD',
83
- input: '#0B2566',
84
- ring: PSYCHE_WARM,
85
- midground: NOUS_BLUE,
86
- composerRing: PSYCHE_WARM,
68
+ background: '#0B1F14',
69
+ foreground: '#DCFCE7',
70
+ card: '#0F2A1B',
71
+ cardForeground: '#DCFCE7',
72
+ muted: '#15301F',
73
+ mutedForeground: '#86B89C',
74
+ popover: '#0F2A1B',
75
+ popoverForeground: '#DCFCE7',
76
+ primary: CLAW_GREEN_LIGHT,
77
+ primaryForeground: '#06140C',
78
+ secondary: '#173B26',
79
+ secondaryForeground: '#D6F5E1',
80
+ accent: CLAW_GREEN_DEEP,
81
+ accentForeground: '#ECFDF3',
82
+ border: '#1E5236',
83
+ input: '#0A1B11',
84
+ ring: CLAW_GREEN_LIGHT,
85
+ midground: CLAW_GREEN,
86
+ composerRing: CLAW_GREEN_LIGHT,
87
87
  destructive: '#C0473A',
88
88
  destructiveForeground: '#FEF2F2',
89
- sidebarBackground: '#09286F',
90
- sidebarBorder: '#234A9C',
91
- userBubble: '#143B91',
92
- userBubbleBorder: '#3A63BD'
89
+ sidebarBackground: '#081A10',
90
+ sidebarBorder: '#1B4A30',
91
+ userBubble: '#143625',
92
+ userBubbleBorder: '#2E6B47'
93
93
  },
94
94
  typography: {
95
95
  fontSans: SYSTEM_SANS,
package/agent/cli.py CHANGED
@@ -2247,6 +2247,13 @@ def _replay_output_history() -> None:
2247
2247
  _OUTPUT_HISTORY_REPLAYING = False
2248
2248
 
2249
2249
 
2250
+ # Internal sentinel enqueued by the model picker to run the deterministic Pod
2251
+ # setup on the process_loop worker thread (see process_command). Not a
2252
+ # user-facing command; the leading slash makes process_loop route it to
2253
+ # process_command rather than treating it as agent chat input.
2254
+ _POD_SETUP_COMMAND = "/__clawpump_pod_setup__"
2255
+
2256
+
2250
2257
  def _cprint(text: str):
2251
2258
  """Print ANSI-colored text through prompt_toolkit's native renderer.
2252
2259
 
@@ -7009,6 +7016,147 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
7009
7016
  else:
7010
7017
  _cprint(" (session only — add --global to persist)")
7011
7018
 
7019
+ def _run_pod_setup_flow(self) -> bool:
7020
+ """Deterministic Pod setup (mirrors the desktop modal): pick wallet →
7021
+ amount → confirm → provision + fund on-chain → switch onto Pod.
7022
+
7023
+ MUST be called on the process_loop worker thread (it is — via the
7024
+ _POD_SETUP_COMMAND sentinel in process_command), NOT the prompt_toolkit
7025
+ event-loop thread: it uses ``_prompt_text_input_modal`` (the app-native
7026
+ modal billing uses) for every step, and the ~30-90s blocking provision
7027
+ runs here without freezing the TUI (the event loop is the main thread).
7028
+
7029
+ Returns True when handled (success OR user cancel); False to fall back to
7030
+ the chat-guided flow (no interactive app, MCP unavailable, no wallets).
7031
+ """
7032
+ if not getattr(self, "_app", None):
7033
+ return False # non-interactive — let the chat-guided flow handle it
7034
+ try:
7035
+ from hermes_cli import distribution as _dist
7036
+ except Exception:
7037
+ return False
7038
+
7039
+ try:
7040
+ wallets = _dist.usepod_fetch_wallets()
7041
+ except Exception:
7042
+ logger.debug("Pod setup: wallet fetch failed", exc_info=True)
7043
+ return False
7044
+ if not wallets:
7045
+ return False
7046
+
7047
+ # 1. Pick the funding wallet (default = most-funded, listed first).
7048
+ wallet_choices = [
7049
+ (
7050
+ w["agent_id"],
7051
+ f"{w.get('name') or (w.get('agent_id') or '')[:8]} — ${(w.get('usdc_balance') or 0):.2f} USDC",
7052
+ "fund the pod from this wallet",
7053
+ )
7054
+ for w in wallets
7055
+ ]
7056
+ wallet_choices.append(("cancel", "Cancel", "do nothing"))
7057
+ raw = self._prompt_text_input_modal(
7058
+ title="⚡ Set up Pod — pay from which agent wallet?",
7059
+ detail="Pod is pay-as-you-go inference funded from a ClawPump wallet.",
7060
+ choices=wallet_choices,
7061
+ )
7062
+ agent_id = self._normalize_slash_confirm_choice(raw, wallet_choices)
7063
+ if not agent_id or agent_id == "cancel":
7064
+ _cprint(" 🟡 Pod setup cancelled.")
7065
+ return True
7066
+ wallet = next((w for w in wallets if w["agent_id"] == agent_id), None)
7067
+ if not wallet:
7068
+ _cprint(" 🟡 Pod setup cancelled.")
7069
+ return True
7070
+
7071
+ # 2. Amount — preset choices (a text modal off the main thread can't
7072
+ # reliably read free text, so offer presets bounded by the balance).
7073
+ bal = wallet.get("usdc_balance") or 0
7074
+ amount_choices = [
7075
+ (str(a), f"${a:.0f} USDC", f"fund ${a:.0f}")
7076
+ for a in (5, 10, 25, 50)
7077
+ if a <= bal
7078
+ ]
7079
+ if not amount_choices:
7080
+ # Balance under $5 — offer the whole balance (min 0.5) or cancel.
7081
+ usable = round(max(bal, 0), 2)
7082
+ if usable < 0.5:
7083
+ _cprint(f" 🔴 Wallet only holds ${bal:.2f} USDC — fund it first.")
7084
+ return True
7085
+ amount_choices = [(str(usable), f"${usable:.2f} USDC (full balance)", "fund the balance")]
7086
+ amount_choices.append(("cancel", "Cancel", "do nothing"))
7087
+ raw = self._prompt_text_input_modal(
7088
+ title=f"⚡ Fund amount (wallet has ${bal:.2f} USDC)",
7089
+ detail="The pod holds a prepaid USDC balance you draw down per request.",
7090
+ choices=amount_choices,
7091
+ )
7092
+ amt_choice = self._normalize_slash_confirm_choice(raw, amount_choices)
7093
+ if not amt_choice or amt_choice == "cancel":
7094
+ _cprint(" 🟡 Pod setup cancelled.")
7095
+ return True
7096
+ try:
7097
+ amount = float(amt_choice)
7098
+ if amount <= 0:
7099
+ raise ValueError
7100
+ except ValueError:
7101
+ _cprint(" 🟡 Pod setup cancelled.")
7102
+ return True
7103
+
7104
+ # 3. Final confirm — the on-chain spend.
7105
+ confirm_choices = [
7106
+ ("pay", f"Fund ${amount:.2f} & use Pod", "submit the on-chain deposit"),
7107
+ ("cancel", "Cancel", "do not spend"),
7108
+ ]
7109
+ raw = self._prompt_text_input_modal(
7110
+ title="⚡ Confirm Pod funding",
7111
+ detail=f"Spend ${amount:.2f} USDC from '{wallet.get('name') or agent_id[:8]}' on-chain. Irreversible.",
7112
+ choices=confirm_choices,
7113
+ )
7114
+ if self._normalize_slash_confirm_choice(raw, confirm_choices) != "pay":
7115
+ _cprint(" 🟡 Pod setup cancelled. No funds moved.")
7116
+ return True
7117
+
7118
+ # 4. Provision + fund (blocking ~30-90s — fine on this worker thread).
7119
+ _cprint(f" ⚡ Provisioning Pod and funding ${amount:.2f} USDC on-chain… (~30-60s)")
7120
+ try:
7121
+ res = _dist.usepod_provision_call(wallet["agent_id"], amount)
7122
+ except Exception as exc:
7123
+ _cprint(f" 🔴 Pod setup failed: {exc}")
7124
+ return True
7125
+
7126
+ api_token = res.get("api_token") or ""
7127
+ if not api_token:
7128
+ _cprint(f" 🔴 Pod setup failed: {res.get('funding_error') or 'no token returned'}")
7129
+ return True
7130
+
7131
+ # 5. Persist + switch. Persist FIRST so a later switch error never loses
7132
+ # a token the user already paid for; guard the switch separately so it
7133
+ # can't bubble to the picker handler and double-handle as chat.
7134
+ _dist.persist_usepod_credentials(api_token, res.get("deposit_code") or "")
7135
+ try:
7136
+ model, _provider, base_url = _dist.usepod_pod_switch_target(
7137
+ api_token, getattr(self, "model", "") or ""
7138
+ )
7139
+ self._pending_pod_activation = {
7140
+ "model": model,
7141
+ "provider": "usepod",
7142
+ "base_url": base_url,
7143
+ "api_key": api_token,
7144
+ }
7145
+ self._activate_pending_pod()
7146
+ except Exception:
7147
+ logger.debug("Pod setup: switch failed (token persisted)", exc_info=True)
7148
+ _cprint(" ✓ Pod funded and saved. Re-open the model picker to select a Pod model.")
7149
+ return True
7150
+
7151
+ if res.get("funding_error"):
7152
+ _cprint(
7153
+ f" ✓ Pod created and selected ({model}). "
7154
+ f"⚠ funding issue: {res['funding_error']} — top it up and retry."
7155
+ )
7156
+ else:
7157
+ _cprint(f" ✓ Pod ready — funded ${amount:.2f} USDC, now using {model}.")
7158
+ return True
7159
+
7012
7160
  def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
7013
7161
  state = self._model_picker_state
7014
7162
  if not state:
@@ -7027,16 +7175,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
7027
7175
  # failing switch. Hand the agent the configure instruction.
7028
7176
  if provider_data.get("clawpump_setup"):
7029
7177
  self._close_model_picker()
7030
- try:
7031
- from hermes_cli.distribution import usepod_setup_request_message
7032
- _msg = usepod_setup_request_message()
7033
- except Exception:
7034
- _msg = (
7035
- "Help me set up the Pod (UsePod) provider step by step and "
7036
- "fund it from my ClawPump wallet."
7037
- )
7178
+ # Run the deterministic Pod setup (pick wallet → amount → confirm
7179
+ # provision) on the process_loop background thread, NOT here:
7180
+ # this handler runs on the prompt_toolkit main/event-loop thread,
7181
+ # where the interactive curses pickers + the ~30-90s on-chain
7182
+ # provision would freeze the TUI. Enqueuing the internal command
7183
+ # routes it through process_command() on the worker thread the
7184
+ # same path the /billing interactive flow uses safely. The flow
7185
+ # itself falls back to the chat-guided message if it can't run.
7038
7186
  if hasattr(self, "_pending_input"):
7039
- self._pending_input.put(_msg)
7187
+ self._pending_input.put(_POD_SETUP_COMMAND)
7040
7188
  return
7041
7189
  # Use the curated model list from list_authenticated_providers()
7042
7190
  # (same lists as `hermes model` and gateway pickers).
@@ -7489,6 +7637,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
7489
7637
  cmd_lower = command.lower().strip()
7490
7638
  cmd_original = command.strip()
7491
7639
 
7640
+ # Internal: deterministic Pod setup, enqueued by the model picker so it
7641
+ # runs HERE on the process_loop worker thread (not the keybinding/event
7642
+ # thread). On this thread the curses pickers do direct terminal I/O and
7643
+ # the slow on-chain provision doesn't block the TUI event loop.
7644
+ if cmd_original == _POD_SETUP_COMMAND:
7645
+ try:
7646
+ if self._run_pod_setup_flow():
7647
+ return True
7648
+ except Exception:
7649
+ logger.debug("Pod setup flow failed; falling back to chat", exc_info=True)
7650
+ # Couldn't run deterministically — hand the agent the guided message.
7651
+ try:
7652
+ from hermes_cli.distribution import usepod_setup_request_message
7653
+ _msg = usepod_setup_request_message()
7654
+ except Exception:
7655
+ _msg = (
7656
+ "Help me set up the Pod (UsePod) provider step by step and "
7657
+ "fund it from my ClawPump wallet."
7658
+ )
7659
+ if hasattr(self, "_pending_input"):
7660
+ self._pending_input.put(_msg)
7661
+ return True
7662
+
7492
7663
  # Resolve aliases via central registry so adding an alias is a one-line
7493
7664
  # change in hermes_cli/commands.py instead of touching every dispatch site.
7494
7665
  from hermes_cli.commands import resolve_command as _resolve_cmd
@@ -14006,7 +14177,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
14006
14177
  continue
14007
14178
 
14008
14179
  if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
14009
- _cprint(f"\n⚙️ {user_input}")
14180
+ # Internal sentinels (e.g. the Pod-setup hand-off from the
14181
+ # model picker) aren't user commands — don't echo them.
14182
+ if user_input != _POD_SETUP_COMMAND:
14183
+ _cprint(f"\n⚙️ {user_input}")
14010
14184
  try:
14011
14185
  if not self.process_command(user_input):
14012
14186
  self._should_exit = True
@@ -406,29 +406,49 @@ def usepod_provision_token(function_name: str, function_result: Any):
406
406
  def usepod_pod_switch_target(api_token: str, current_model: str = ""):
407
407
  """Return ``(model, provider, base_url)`` to switch onto for a Pod token.
408
408
 
409
- Keeps the user's current model when UsePod's offline catalog lists it,
410
- otherwise falls back to the provider's first advertised Pod model. The
411
- secret-bearing base_url is derived from the token (never persisted to
412
- config.yaml).
409
+ Keeps the user's current model only when the pod's LIVE catalog actually
410
+ serves it; otherwise picks a pod-served model. Using the live catalog (not
411
+ the static ``fallback_models``) matters: the static list includes names like
412
+ ``gpt-5.5`` that the pod does NOT serve, so keeping the user's old model
413
+ there left them on a model the pod can't run — the runtime then fell back to
414
+ that model's native provider (e.g. Codex) and errored. The secret-bearing
415
+ base_url is derived from the token (never persisted to config.yaml).
413
416
  """
414
417
  provider = "usepod"
415
- models = []
418
+ pp = None
416
419
  try:
417
420
  from providers import get_provider_profile
418
421
 
419
422
  pp = get_provider_profile(provider)
420
- models = list(getattr(pp, "fallback_models", ()) or [])
423
+ except Exception:
424
+ pp = None
425
+
426
+ # Live catalog first (what the pod really serves), then the static list.
427
+ models = []
428
+ try:
429
+ if pp is not None and api_token:
430
+ live = pp.fetch_models(api_key=api_token)
431
+ if live:
432
+ models = list(live)
421
433
  except Exception:
422
434
  models = []
435
+ if not models:
436
+ models = list(getattr(pp, "fallback_models", ()) or []) if pp else []
423
437
 
424
438
  model = ""
425
439
  cur = (current_model or "").strip()
426
440
  if cur and cur in models:
427
441
  model = cur
428
442
  elif models:
429
- model = models[0]
443
+ # Prefer a sensible default if present, else the first served model.
444
+ for pref in ("claude-opus-4-8", "claude-sonnet-4-6"):
445
+ if pref in models:
446
+ model = pref
447
+ break
448
+ if not model:
449
+ model = models[0]
430
450
  else:
431
- model = cur or "claude-opus-4-8"
451
+ model = "claude-opus-4-8"
432
452
 
433
453
  base_url = ""
434
454
  try:
@@ -455,3 +475,163 @@ def persist_usepod_credentials(api_token: str, deposit_code: str = "") -> bool:
455
475
  return True
456
476
  except Exception:
457
477
  return False
478
+
479
+
480
+ # ── Deterministic Pod setup (terminal flow, mirrors the desktop modal) ─────
481
+ # Lets the TUI run a clean "pick wallet → amount → confirm → provision" flow
482
+ # directly, instead of injecting a chat message and asking the agent to do it.
483
+
484
+
485
+ def _clawpump_mcp_config():
486
+ """Return (server_name, resolved_config) for the ClawPump MCP, or (None, None)."""
487
+ try:
488
+ from hermes_cli.mcp_config import _get_mcp_servers, _resolve_mcp_server_config
489
+
490
+ servers = _get_mcp_servers()
491
+ name = next((n for n in ("clawpump", "clawpump-stdio") if n in servers), None)
492
+ if not name:
493
+ return (None, None)
494
+ return (name, _resolve_mcp_server_config(servers[name]))
495
+ except Exception:
496
+ return (None, None)
497
+
498
+
499
+ def _clawpump_tool_struct(tool: str, arguments=None, *, timeout: float = 120):
500
+ """Call a ClawPump MCP tool, returning its structuredContent when present.
501
+
502
+ Like ``mcp_config._call_single_tool`` but keeps ``structuredContent`` — the
503
+ machine channel where ``usepod_provision`` puts the REAL api_token (the text
504
+ channel redacts it). Falls back to the parsed text JSON otherwise. Blocking.
505
+ """
506
+ import json as _json
507
+
508
+ name, config = _clawpump_mcp_config()
509
+ if not name:
510
+ raise RuntimeError("ClawPump MCP is not configured. Run `claw clawpump setup`.")
511
+
512
+ from tools.mcp_tool import (
513
+ _ensure_mcp_loop,
514
+ _run_on_mcp_loop,
515
+ _connect_server,
516
+ _stop_mcp_loop_if_idle,
517
+ )
518
+
519
+ _ensure_mcp_loop()
520
+ out = {"structured": None, "text": "", "error": None}
521
+
522
+ async def _call():
523
+ import asyncio as _asyncio
524
+
525
+ server = await _asyncio.wait_for(_connect_server(name, config), timeout=timeout)
526
+ try:
527
+ result = await server.session.call_tool(tool, arguments=arguments or {})
528
+ text = "".join(b.text for b in (result.content or []) if hasattr(b, "text"))
529
+ if getattr(result, "isError", False):
530
+ out["error"] = text or "MCP tool returned an error"
531
+ return
532
+ out["text"] = text
533
+ sc = getattr(result, "structuredContent", None)
534
+ if isinstance(sc, dict) and sc:
535
+ out["structured"] = sc
536
+ finally:
537
+ await server.shutdown()
538
+
539
+ try:
540
+ _run_on_mcp_loop(_call(), timeout=timeout + 30)
541
+ finally:
542
+ try:
543
+ _stop_mcp_loop_if_idle()
544
+ except Exception:
545
+ pass
546
+
547
+ if out["error"]:
548
+ raise RuntimeError(out["error"])
549
+ # Both channels: structuredContent (machine; real secrets) AND the text
550
+ # (human; may redact secrets). Callers that need a secret check structured
551
+ # first, then fall back to the text — never drop one silently.
552
+ return {"structured": out["structured"], "text": out["text"]}
553
+
554
+
555
+ def usepod_fetch_wallets():
556
+ """Return [{agent_id, name, usdc_balance}] for the user's ClawPump wallets.
557
+
558
+ Sorted by USDC balance (most-funded first) so the picker can default to it.
559
+ """
560
+ summaries = _channel_payload(_clawpump_tool_struct("get_wallet_summaries", {}, timeout=30))
561
+ if isinstance(summaries, dict):
562
+ summaries = summaries.get("wallets") or summaries.get("summaries") or []
563
+ rows = [w for w in (summaries or []) if isinstance(w, dict) and w.get("agent_id")]
564
+
565
+ # Enrich with display names (summaries carry only the agent_id UUID).
566
+ try:
567
+ agents = _channel_payload(_clawpump_tool_struct("list_agents", {}, timeout=30))
568
+ if isinstance(agents, dict):
569
+ agents = agents.get("agents") or []
570
+ names = {a.get("id"): a.get("name") for a in (agents or []) if isinstance(a, dict)}
571
+ for w in rows:
572
+ w["name"] = names.get(w.get("agent_id")) or w.get("name")
573
+ except Exception:
574
+ pass
575
+
576
+ rows.sort(key=lambda w: (w.get("usdc_balance") or 0), reverse=True)
577
+ return rows
578
+
579
+
580
+ def _channel_payload(channels):
581
+ """Pick the usable object from a ``{structured, text}`` channel result:
582
+ structuredContent if present, else the parsed text JSON, else the raw."""
583
+ if not isinstance(channels, dict) or ("structured" not in channels and "text" not in channels):
584
+ return channels # already a plain payload (back-compat)
585
+ sc = channels.get("structured")
586
+ if isinstance(sc, dict) and sc:
587
+ return sc
588
+ text = channels.get("text")
589
+ if isinstance(text, str) and text:
590
+ try:
591
+ import json as _json
592
+
593
+ return _json.loads(text)
594
+ except Exception:
595
+ return text
596
+ return None
597
+
598
+
599
+ def usepod_provision_call(agent_id: str, amount: float) -> dict:
600
+ """Register + fund a Pod from ``agent_id``'s wallet. Spends on-chain USDC.
601
+
602
+ Returns the parsed result with the REAL api_token. The token lives in
603
+ ``structuredContent`` (the text channel redacts it), but we check BOTH —
604
+ structured first, then the text via the regex extractor — so a money-spent
605
+ provision never reports "no token" just because the channels were swapped.
606
+ """
607
+ channels = _clawpump_tool_struct(
608
+ "usepod_provision",
609
+ {"agent_id": agent_id, "amount": amount, "confirm_deposit": True},
610
+ timeout=120,
611
+ )
612
+ data = _channel_payload(channels)
613
+ if not isinstance(data, dict):
614
+ data = {}
615
+
616
+ def _real(tok: str) -> str:
617
+ # The redacted placeholder ("<applied… hidden>") is truthy but useless —
618
+ # treat it as no-token so we never persist it as a working credential.
619
+ tok = (tok or "").strip()
620
+ return "" if (not tok or tok.startswith("<") or "applied" in tok.lower()) else tok
621
+
622
+ # 1. structuredContent dict, 2. its nested wrappers, 3. the raw text regex.
623
+ api_token = _real(str(data.get("api_token") or data.get("token") or ""))
624
+ if not api_token:
625
+ tok = usepod_provision_token("mcp_clawpump_usepod_provision", data)
626
+ api_token = _real(tok[0] if tok else "")
627
+ if not api_token and isinstance(channels, dict):
628
+ tok = usepod_provision_token("mcp_clawpump_usepod_provision", channels.get("text") or "")
629
+ api_token = _real(tok[0] if tok else "")
630
+
631
+ return {
632
+ "api_token": api_token,
633
+ "deposit_code": str(data.get("deposit_code") or "").strip(),
634
+ "signature": str(data.get("signature") or ""),
635
+ "funding_error": str(data.get("funding_error") or ""),
636
+ "amount": data.get("amount"),
637
+ }
@@ -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