dexterm-cli 0.1.1 → 0.1.3

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 (155) hide show
  1. package/dist/packages/agents/src/bus.js +57 -6
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/follow.js +105 -0
  4. package/dist/packages/agents/src/gate.js +3 -0
  5. package/dist/packages/agents/src/identity.js +41 -2
  6. package/dist/packages/agents/src/index.js +5 -0
  7. package/dist/packages/agents/src/spectator-server.js +153 -0
  8. package/dist/packages/agents/src/spectator.js +212 -0
  9. package/dist/packages/agents/src/tiers.js +5 -0
  10. package/dist/packages/agents/src/tools.js +3 -0
  11. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  12. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  13. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  14. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  15. package/dist/packages/cli/src/agent-cmd.js +107 -10
  16. package/dist/packages/cli/src/agent-spectator.js +37 -0
  17. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  18. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  19. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  20. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  21. package/dist/packages/cli/src/alerts/creds.js +83 -0
  22. package/dist/packages/cli/src/alerts/format.js +66 -0
  23. package/dist/packages/cli/src/alerts/index.js +10 -0
  24. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  25. package/dist/packages/cli/src/alerts/route.js +19 -0
  26. package/dist/packages/cli/src/alerts/types.js +1 -0
  27. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  28. package/dist/packages/cli/src/app-help-gen.js +365 -0
  29. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  30. package/dist/packages/cli/src/coin-target.js +2 -0
  31. package/dist/packages/cli/src/doctor.js +21 -6
  32. package/dist/packages/cli/src/export.js +235 -0
  33. package/dist/packages/cli/src/follow-cmd.js +107 -1
  34. package/dist/packages/cli/src/help-cmd.js +48 -0
  35. package/dist/packages/cli/src/live/index.js +66 -3
  36. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  37. package/dist/packages/cli/src/live/signals.js +109 -0
  38. package/dist/packages/cli/src/live/trade.js +24 -1
  39. package/dist/packages/cli/src/main.js +596 -49
  40. package/dist/packages/cli/src/mcp-cmd.js +321 -0
  41. package/dist/packages/cli/src/pnl-app.js +221 -0
  42. package/dist/packages/cli/src/pnl-card.js +615 -0
  43. package/dist/packages/cli/src/record-cmd.js +455 -0
  44. package/dist/packages/cli/src/rules.js +69 -2
  45. package/dist/packages/cli/src/screens/follows.js +91 -2
  46. package/dist/packages/cli/src/screens/overlay.js +3 -0
  47. package/dist/packages/cli/src/screens/positions.js +1 -1
  48. package/dist/packages/cli/src/screens/rules.js +1 -1
  49. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  50. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  51. package/dist/packages/cli/src/state.js +1 -1
  52. package/dist/packages/cli/src/update.js +294 -0
  53. package/dist/packages/cli/src/version.js +3 -0
  54. package/dist/packages/cli/src/voice-cmd.js +194 -0
  55. package/dist/packages/cli/src/voice-hud.js +1 -1
  56. package/dist/packages/cli/src/watch-cmd.js +190 -0
  57. package/dist/packages/custom/src/rules.js +3 -2
  58. package/dist/packages/custom/src/schema.js +24 -1
  59. package/dist/packages/data/src/analyst-actions.js +130 -3
  60. package/dist/packages/data/src/coin-target.js +82 -0
  61. package/dist/packages/data/src/fast-lane.js +16 -2
  62. package/dist/packages/data/src/index.js +4 -0
  63. package/dist/packages/data/src/intel/helius-history.js +12 -1
  64. package/dist/packages/data/src/intel/live-signals.js +185 -0
  65. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  66. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  67. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  68. package/dist/packages/data/src/trade/copy/index.js +1 -1
  69. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  70. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  71. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  72. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  73. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  74. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  75. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  76. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  77. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  78. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  79. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  80. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  81. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  82. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  83. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  84. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  85. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  89. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  90. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  91. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  92. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  93. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  94. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  95. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  96. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  97. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  98. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  99. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  100. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  101. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  102. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  103. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  104. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  105. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  106. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  107. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  108. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  109. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  110. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  111. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  112. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  113. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  114. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  115. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  116. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  117. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  118. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  119. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  120. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  121. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  122. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  123. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  124. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  125. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  126. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  127. package/dist/packages/mcp/src/app-help.js +135 -0
  128. package/dist/packages/mcp/src/index.js +3 -0
  129. package/dist/packages/mcp/src/server.js +179 -5
  130. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  131. package/dist/packages/packs/src/index.js +3 -0
  132. package/dist/packages/packs/src/skill-backtest.js +300 -0
  133. package/dist/packages/packs/src/skill-care.js +206 -0
  134. package/dist/packages/popup/src/pages/spectator.css +45 -0
  135. package/dist/packages/popup/src/pages/spectator.html +25 -0
  136. package/dist/packages/popup/src/pages/spectator.js +74 -0
  137. package/dist/packages/popup/src/selftest.js +19 -0
  138. package/dist/packages/popup/src/server.js +7 -1
  139. package/dist/packages/tui/src/index.js +3 -0
  140. package/dist/packages/tui/src/keymap.js +12 -0
  141. package/dist/packages/tui/src/raster-font.js +195 -0
  142. package/dist/packages/tui/src/raster.js +213 -0
  143. package/dist/packages/voice/src/controller.js +57 -4
  144. package/dist/packages/voice/src/doctor.js +96 -11
  145. package/dist/packages/voice/src/download.js +184 -0
  146. package/dist/packages/voice/src/engines.js +55 -8
  147. package/dist/packages/voice/src/index.js +9 -3
  148. package/dist/packages/voice/src/speakable.js +111 -0
  149. package/dist/packages/voice/src/stt/whisper.js +14 -4
  150. package/dist/packages/voice/src/tts/espeak.js +131 -0
  151. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  152. package/dist/packages/voice/src/tts/piper.js +449 -0
  153. package/dist/packages/voice/src/tts/say.js +72 -35
  154. package/dist/packages/voice/src/tts/voices.js +209 -0
  155. package/package.json +5 -9
@@ -17,8 +17,13 @@
17
17
  import { writeFileSync, existsSync } from 'node:fs';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { dirname, join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { writeExport, exportViewFor } from "./export.js";
22
+ import { openPnl, loadPnl, pnlKey } from "./pnl-app.js";
23
+ import { shareCard, runLocal } from "./pnl-card.js";
24
+ import { parseMintTarget, parseCoinTarget, resolveCoinTarget, isBareOpen } from "./coin-target.js";
20
25
  import { THEMES, isThemeName, DEFAULT_THEME, THEME_NAMES, Flusher, ENTER_SCREEN, LEAVE_SCREEN, DEFAULT_KEYMAP, Keymap, nextTheme, parseKeys, keyId, detectColorDepth, parseColorDepth, blinkFrame, blinkFrames, stepVfx, pulse, isCalm, fxBusy, blendActivity, ImageLayer, queryTerminal, resolveProtocol, fireMoment, fireInflight, clearInflight, skipMoment, momentKindFor, playMoment, soundOn, breakpoint, fmtUsd as fmtUsdTui, } from '../../tui/src/index.js';
21
- import { resolveRpcUrl, redactSecrets, defaultDbPath, extraRpcUrls, wsUrlFor, SOLANA_PUBLIC_RPC_WS, parseHttpUrl, validateAnalystAction, ACTION_RATE, ImageService, encodePng, SummaryLimiter, buildSummaryWindow, KolStore, Cache, PackError, normalizeHandle, terminalSafe } from '../../data/src/index.js';
26
+ import { resolveRpcUrl, redactSecrets, defaultDbPath, extraRpcUrls, wsUrlFor, SOLANA_PUBLIC_RPC_WS, parseHttpUrl, validateAnalystAction, ACTION_RATE, ACTION_TIER, configChangeForAction, ImageService, encodePng, SummaryLimiter, buildSummaryWindow, KolStore, Cache, PackError, normalizeHandle, terminalSafe } from '../../data/src/index.js';
22
27
  import { Recorder, recorderEnabled, MaintenanceSchedule } from '../../recorder/src/index.js';
23
28
  import { makeFixtureState, makeFeedState, syncFeed, renderFrame, isScreenKey, activityFor, SCREEN_NAMES, analystKey, syncAnalyst, pinNewpairsSelection, ANALYST_TAB_KEY, computeNarrativeView, newPlansUi, plansService, plansRows, newFollowsUi, copyService, copyAutoService, MIRROR_DEFAULT_CAP_USD } from "./state.js";
24
29
  import { mulberry32, tick, spawnPair, shock } from "./fixtures.js";
@@ -35,6 +40,9 @@ import { TokenPopups, tokenDetailVMFromView } from "./popup.js";
35
40
  import { widgetPage, widgetEnabled, syncWidgetPopups } from "./widgets.js";
36
41
  import { recordLarpVerdict } from "./larp-record.js";
37
42
  import { rulesUiOf, rulesTick, rulesMove, beginRuleInput, saveRuleInput, dryRunNow, removeSelectedRule, toggleSelectedRule, dismissSelectedProposal, actOnSelectedProposal, RULE_INPUT_MAX } from "./rules.js";
43
+ import { buildNotifier, hasPushChannel } from "./alerts/notifier.js";
44
+ import { copyAlertToDelivery } from "./alerts/format.js";
45
+ import { readTelegramCreds, readDiscordCreds } from "./alerts/creds.js";
38
46
  import { launchIntelCells, launchRetryable } from "./live/intel.js";
39
47
  import { AnalystSession } from "./analyst/session.js";
40
48
  import { resolveAnalystResults } from "./analyst/results.js";
@@ -46,13 +54,14 @@ import { buildObservations, observeSig } from "./scan/observe.js";
46
54
  import { cleanText } from "./screener/rows.js";
47
55
  import { resolveImagesConfig } from "./images-config.js";
48
56
  import { popupImagePath } from "./screens/coin.js";
49
- import { VoiceController, selectStt, selectTts } from '../../voice/src/index.js';
57
+ import { VoiceController, selectStt, selectTts, SayTts, voiceStyleDefaults } from '../../voice/src/index.js';
50
58
  import { LivePlanExecutor, defaultPlansJournalPath } from '../../data/src/trade/plans/index.js';
51
59
  import { isWalletAddress, shortAddr, buildMirror, leaderSoldSince, autoSizeCap } from '../../data/src/trade/copy/index.js';
52
60
  import { FOLLOW_INPUT_MAX } from "./screens/follows.js";
53
61
  import { isPriceMode, configPriceMode, togglePriceMode, priceModeOf, planLevels } from "./display.js";
62
+ import { VERSION } from "./version.js";
54
63
  import { initCustom, planWorkspace, planPopupOpens, findWorkspace, nextWorkspaceName, workspaceSlots, saveWorkspace, themeFor, themeByName, cycleTheme, themeNames, CHORD_TTL_MS, MAX_WORKSPACE_NAME } from "./custom.js";
55
- import { DEFAULT_WORKSPACE, MORNING_HUD } from '../../custom/src/index.js';
64
+ import { DEFAULT_WORKSPACE, MORNING_HUD, applyAndJournal, policyCheck } from '../../custom/src/index.js';
56
65
  /**
57
66
  * Context the `screen_state` MCP tool returns AND the per-turn context block is rendered from
58
67
  * (task-1313): what the user is looking at right now — selected/hovered coin, screen, the visible
@@ -60,6 +69,37 @@ import { DEFAULT_WORKSPACE, MORNING_HUD } from '../../custom/src/index.js';
60
69
  * that bounds what the analyst's UI actions may reference. Token-derived text stays raw here; the
61
70
  * context-block renderer wraps it as untrusted before a model sees it.
62
71
  */
72
+ /** Screens the analyst's `switch_screen` action may target (task-1413); the user-facing set. */
73
+ export const SWITCHABLE_SCREENS = ['screener', 'token', 'newpairs', 'watchlist', 'analyst', 'positions', 'trending', 'kol'];
74
+ /**
75
+ * A read-only snapshot of local UI state for the `app_state` MCP tool (task-1413): what the user sees.
76
+ * NEVER includes a secret — alert channels report a boolean "configured", computed from creds presence,
77
+ * never the token/webhook/key itself. `null` when there is no live config (one-shot fixture frames).
78
+ */
79
+ export function appStateBlock(state) {
80
+ const ui = state.custom;
81
+ if (!ui)
82
+ return null;
83
+ const cfg = ui.config;
84
+ const home = ui.home;
85
+ const ch = (cfg.alerts?.channels ?? {});
86
+ const alerts = {
87
+ // toast is on unless explicitly disabled; the rest are off unless enabled. telegram/discord also need creds.
88
+ toast: ch.toast?.enabled !== false,
89
+ desktop: ch.desktop?.enabled === true,
90
+ telegram: ch.telegram?.enabled === true && readTelegramCreds(home) !== null,
91
+ discord: ch.discord?.enabled === true && readDiscordCreds(home) !== null,
92
+ };
93
+ return {
94
+ theme: state.theme.name,
95
+ priceMode: priceModeOf(state),
96
+ intensity: state.vfx.intensity,
97
+ calm: isCalm(state.vfx),
98
+ voice: { name: cfg.voice?.name ?? null, rate: cfg.voice?.rate ?? null },
99
+ scan: { on: state.scan?.engine.on ?? false },
100
+ alerts, // booleans only — never a token, webhook or key
101
+ };
102
+ }
63
103
  export function buildScreenState(state) {
64
104
  const live = state.live;
65
105
  const sc = state.screener;
@@ -122,7 +162,14 @@ export function buildScreenState(state) {
122
162
  watchlist,
123
163
  ticket,
124
164
  positions,
125
- actions: { enabled: !!live, mints: [...mints], feeds, sortKeys: SORT_KEYS.map((k) => k.key) },
165
+ actions: {
166
+ enabled: !!live, mints: [...mints], feeds, sortKeys: SORT_KEYS.map((k) => k.key),
167
+ // task-1413: targets the new tiered actions may reference.
168
+ screens: [...SWITCHABLE_SCREENS],
169
+ workspaces: Object.keys(state.custom?.config.layout?.workspaces ?? {}),
170
+ themes: themeNames(state.custom),
171
+ },
172
+ app: appStateBlock(state),
126
173
  now: Date.now(),
127
174
  };
128
175
  }
@@ -205,10 +252,13 @@ export function shouldDrawFrame(g) {
205
252
  return true;
206
253
  return g.frame % g.idleEvery === 0;
207
254
  }
208
- export const HELP = `dexterm 0.1.1 (live: screener, token, new pairs, watchlist · analyst = your local claude CLI · cyberspace look)
255
+ export const HELP = `dexterm ${VERSION} (live: screener, token, new pairs, watchlist · analyst = your local claude CLI · cyberspace look)
209
256
 
210
257
  dexterm interactive, live data (needs a TTY)
211
258
  dexterm doctor check this machine is ready (Node, colour, claude, network, Helius key)
259
+ dexterm voice [status|download <model>] voice engines on this machine; download whisper/piper models (asks first, checksum-verified)
260
+ dexterm update [--off|--on] check npm + GitHub for a newer version and print the upgrade command;
261
+ --off/--on toggles the once-a-day startup notice (no telemetry)
212
262
  dexterm recorder stats|label|prune|export the history recorder's tables (no network; 'dexterm recorder help')
213
263
  dexterm kb list|show|new|edit|check|reset|path your knowledge-base entries (~/.dexterm/kb/entries) over the bundled 17
214
264
  dexterm skill list|show|new|edit|check|reset|path your analyst skills (~/.dexterm/skills) over the bundled ones (read tools only)
@@ -217,7 +267,11 @@ export const HELP = `dexterm 0.1.1 (live: screener, token, new pairs, watchlist
217
267
  dexterm wallet link|unlink|status|panic|resume link a Phantom wallet via a local bridge (pubkey only); panic disables live trading, resume needs a TTY keypress
218
268
  dexterm plan propose|list|arm|cancel|journal conditional order plans (entry/ladder/take-profit/moon bag/stop); arming needs a physical keypress
219
269
  dexterm mandate draft|list|arm|revoke standing capped mandates (fast wallet); arming needs a physical keypress — voice/agents can never arm
220
- dexterm follow add|remove|list|stats|trades|purge copy trading tier 1: follow a wallet (your list, local labels), its decoded swaps + FIFO PnL; alerts in the app
270
+ dexterm follow add|remove|list|stats|trades|mirror|purge copy trading tier 1: follow a wallet (your list, local labels), its decoded swaps + FIFO PnL + win rate/early-entry; 'mirror' previews a one-key MIRROR TICKET from the wallet's latest buy (inert; arm in the app)
271
+ dexterm mcp [install <client>|doctor] read-only MCP server for your own agent; 'install' writes the config for claude-code|claude-desktop|cursor|codex|generic (asks first, idempotent, backs up); 'doctor' verifies the connection
272
+ dexterm alerts status|enable|disable|set-telegram|set-discord|test where rule alerts are delivered (desktop / your Telegram / Discord; creds stay local, never logged)
273
+ dexterm watch [--interval S] [--once] [--replay FILE] run your rules headless and push alerts when the terminal is closed (no signer, proposals only)
274
+ dexterm record run|install|uninstall|status keep the history recorder fed with no TUI open, as a user-level launchd agent (no network beyond the data layer; 'dexterm record help')
221
275
  dexterm --fixtures interactive demo on seeded fixtures
222
276
  dexterm --screen <name> one-shot frame to stdout (live)
223
277
  dexterm --screen <name> --fixtures one-shot frame from fixtures
@@ -232,7 +286,7 @@ export const HELP = `dexterm 0.1.1 (live: screener, token, new pairs, watchlist
232
286
  --record FILE save every live response (and the PumpPortal session) to FILE (replay it with --replay)
233
287
  --max-requests N hard cap on live requests for this run (default: none)
234
288
  --max-credits N hard cap on Helius credits for this run (default: 5000)
235
- --mint MINT token to open on the token screen
289
+ --mint MINT|LINK token to open on the token screen (a CA or a dexscreener / pump.fun / solscan / birdeye link)
236
290
  --interval IV candle interval for the token screen: ${INTERVALS.join(' | ')} (default 1h)
237
291
  --listen SECONDS one-shot new pairs: how long to listen to PumpPortal (default 20)
238
292
  --db PATH SQLite path for cache, watchlist, credit meter and history (default: ~/.local/share/dexterm/dexterm.db; ':memory:' for none)
@@ -296,6 +350,7 @@ ${X_HELP}
296
350
  --plain monochrome text (snapshot surface)
297
351
  --ansi ANSI output even when stdout is not a TTY (24-bit unless --color / NO_COLOR)
298
352
  --html FILE write a standalone HTML page (for screenshots); --fit WxH --font PX for video frames
353
+ --export [DIR] write the frame as a PNG + a text report with data ages (default ~/.dexterm/exports; local only)
299
354
  --color DEPTH truecolor | 256 | none (default: detected; NO_COLOR honoured)
300
355
  --tz ZONE header clock zone (default America/New_York, the prototype's; 'local' = machine zone)
301
356
  --title TEXT HTML page title
@@ -303,9 +358,10 @@ ${X_HELP}
303
358
  --version print the version
304
359
 
305
360
  Keys: 1-4 screens · tab cycle · j/k move · ⏎ open · esc back · a analyst · v voice · t theme · c calm · p pause · q quit
306
- voice (JARVIS, macOS): v push-to-talk (press to talk, press to send) · m mute · , change voice · esc stop speaking
361
+ voice (JARVIS, macOS): v push-to-talk (press to talk, press to send) · m mute · , change voice · < / > slower/faster · esc stop speaking
307
362
  voice asks the analyst or triggers safe actions (open/filter/watch/open a PAPER ticket);
308
363
  a spoken "buy"/"sell" only OPENS a ticket — you still confirm it with a keypress
364
+ spoken replies talk in market cap and round numbers (never a raw per-token price); < / > set the rate (saved to config)
309
365
  screener: / filter · s sort · [ ] presets · r refresh · g/G top/bottom
310
366
  social (task-1330): T trending in the space · K KOL lists · token screen: s social panel · f filter · x hidden spam
311
367
  dexterm kol list | create <name> | add <list> @handle | remove <list> @handle | mute @handle | unmute @handle | enable <list> | disable <list> | import <dir> | import-x
@@ -321,12 +377,14 @@ ${X_HELP}
321
377
  priced from live Jupiter quotes. LIVE (L on a ticket, /wallet link first): a separate signer process
322
378
  quotes, builds, decodes, simulates and enforces the caps; Phantom signs in your browser; dexterm never
323
379
  holds a key. The analyst, voice and scan mode can never open, confirm or sign a live trade.
324
- RPC: DEXTERM_RPC_URL, else ~/.dexterm/helius.key (Helius, metered with a credit meter), else the public RPC.`;
380
+ RPC: DEXTERM_RPC_URL, else ~/.dexterm/helius.key (Helius, metered with a credit meter), else the public RPC.
381
+ Updates: at most once a day a live run asks npm + GitHub for the newest version (two anonymous GETs, no telemetry)
382
+ and shows one toast if there is one; DEXTERM_NO_UPDATE_CHECK=1 or 'dexterm update --off' turns that off.`;
325
383
  export function parseArgs(argv) {
326
384
  const a = {
327
385
  screen: null, fixtures: false, replay: null, record: null, filter: null, sort: null, preset: null, maxRequests: 0, maxCredits: 0, db: null,
328
386
  mint: null, interval: '1h', listen: 20, verbose: false,
329
- width: null, height: null, html: null, theme: DEFAULT_THEME, selected: 0, seed: 7,
387
+ width: null, height: null, html: null, export: null, theme: DEFAULT_THEME, selected: 0, seed: 7,
330
388
  noAi: false, title: null, plain: false, ansi: false, color: null, tz: null,
331
389
  calm: false, intensity: null, fps: null, frame: 0, t: 1, event: null, ai: null, mood: null, activity: null, noBoot: false, fit: null, fontPx: null, asciiRain: false, noImages: false,
332
390
  scan: false, scanConfig: {},
@@ -375,10 +433,13 @@ export function parseArgs(argv) {
375
433
  else if (k === '--max-credits')
376
434
  a.maxCredits = num();
377
435
  else if (k === '--mint') {
436
+ // task-1401: a bare mint, or a dexscreener / pump.fun / solscan / birdeye link (a dexscreener pool is resolved to its coin at open)
378
437
  const m = str();
379
- if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(m))
380
- throw new Error(`--mint expects a base58 Solana address, got ${JSON.stringify(m.slice(0, 20))}`);
381
- a.mint = m;
438
+ const t = parseCoinTarget(m);
439
+ if (!t || (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(m.trim()) && t.via === 'address'))
440
+ throw new Error(`--mint expects a base58 Solana address or a coin link, got ${JSON.stringify(m.slice(0, 20))}`);
441
+ a.mint = t.address;
442
+ a.mintVia = t.via;
382
443
  }
383
444
  else if (k === '--interval') {
384
445
  const iv = str();
@@ -398,6 +459,8 @@ export function parseArgs(argv) {
398
459
  a.height = num();
399
460
  else if (k === '--html')
400
461
  a.html = str();
462
+ else if (k === '--export')
463
+ a.export = argv[i + 1] && !argv[i + 1].startsWith('--') ? str() : '';
401
464
  else if (k === '--theme')
402
465
  a.theme = str();
403
466
  else if (k === '--selected')
@@ -694,11 +757,11 @@ export function fastLaneEndpoints(rpc) {
694
757
  return out;
695
758
  }
696
759
  export async function main(argv, io = process, proc = process) {
697
- // `dexterm mcp` → the standalone read-only MCP server on stdio (SPEC §5.1; `claude mcp add dexterm -- dexterm mcp`).
760
+ // `dexterm mcp` → the read-only MCP server on stdio (SPEC §5.1; `claude mcp add dexterm -- dexterm mcp`),
761
+ // plus `dexterm mcp install <client>` (one-command setup) and `dexterm mcp doctor` (verify the connection).
698
762
  if (argv[0] === 'mcp') {
699
- const { runStdioServer } = await import('../../mcp/src/index.js');
700
- await runStdioServer({ argv: argv.slice(1) });
701
- return 0;
763
+ const { runMcp } = await import("./mcp-cmd.js");
764
+ return runMcp(argv.slice(1), io);
702
765
  }
703
766
  // `dexterm agent <cmd>` → bring-your-own-agent registry + loopback bus (task-1345): add/list/rotate/
704
767
  // remove/tier/pause/resume/kick/audit/packs, `serve` (a standalone bus on fixtures) and `connect`.
@@ -706,6 +769,12 @@ export async function main(argv, io = process, proc = process) {
706
769
  const { runAgent } = await import("./agent-cmd.js");
707
770
  return runAgent(argv.slice(1), io);
708
771
  }
772
+ // `dexterm spectate --replay FILE | --info` → watch an agent drive dexterm (task-1406, "watch the agent
773
+ // cook"): replay a saved session, or show the live read-only, token-gated, loopback spectator stream.
774
+ if (argv[0] === 'spectate') {
775
+ const { runSpectate } = await import("./spectate-cmd.js");
776
+ return runSpectate(argv.slice(1), io);
777
+ }
709
778
  // `dexterm recorder <cmd>` → the history recorder's tables: stats, label, prune, export (task-1329). No network.
710
779
  if (argv[0] === 'recorder') {
711
780
  const { recorderCli } = await import('../../recorder/src/index.js');
@@ -741,6 +810,12 @@ export async function main(argv, io = process, proc = process) {
741
810
  const { kbSkillCommand } = await import("./kb-skill-cmd.js");
742
811
  return kbSkillCommand(argv[0], argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env, isTTY: io.stdout.isTTY === true });
743
812
  }
813
+ // `dexterm help [topic]` → the app-help KB (task-1413): how the app works, with the exact key/command.
814
+ // Backed by the same generated entries the analyst's `app_help` tool uses. No network, no model.
815
+ if (argv[0] === 'help') {
816
+ const { helpCommand } = await import("./help-cmd.js");
817
+ return helpCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr });
818
+ }
744
819
  // `dexterm theme list|show|init|check` → user themes under ~/.dexterm/themes (task-1358 Tier 2). No network.
745
820
  if (argv[0] === 'theme') {
746
821
  const { themeCommand } = await import("./theme-cmd.js");
@@ -761,6 +836,25 @@ export async function main(argv, io = process, proc = process) {
761
836
  const { rulesCommand } = await import("./rules-cmd.js");
762
837
  return rulesCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env });
763
838
  }
839
+ // `dexterm alerts status|enable|disable|levels|proposals|set-telegram|set-discord|test` → where rule alerts
840
+ // are delivered (task-1398). Credentials live in ~/.dexterm/*.json (0600), never config.json/argv/logs.
841
+ if (argv[0] === 'alerts') {
842
+ const { alertsCommand } = await import("./alerts-cmd.js");
843
+ return alertsCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env, stdin: io.stdin });
844
+ }
845
+ // `dexterm watch [--interval S] [--once|--ticks N] [--replay FILE]` → run the rules engine headless and
846
+ // PUSH alerts to the enabled channels so they reach you when the terminal is closed (task-1398). No signer.
847
+ if (argv[0] === 'watch') {
848
+ const { watchCommand } = await import("./watch-cmd.js");
849
+ return watchCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env });
850
+ }
851
+ // `dexterm record [run|install|uninstall|status]` → the headless collector that keeps the history recorder
852
+ // fed with no TUI open (task-1414), to be driven by a user-level launchd LaunchAgent. No signer, no trading;
853
+ // requests honour the data layer limits / health gate / sweep budgets, and the Helius key is never printed.
854
+ if (argv[0] === 'record') {
855
+ const { recordCommand } = await import("./record-cmd.js");
856
+ return recordCommand(argv.slice(1), { stdout: io.stdout, stderr: io.stderr, env: io.env });
857
+ }
764
858
  // `dexterm pack list|verify|add|remove|export|lock|sign|trust|signers|keymap|widget` → shareable packs (task-1358 Tier 4):
765
859
  // plain files + sha256 lock + optional ssh signature. No network, no model; a pack never runs code (widgets land
766
860
  // disabled, unsigned rules load disabled, a keymap merges only on the explicit command).
@@ -789,6 +883,18 @@ export async function main(argv, io = process, proc = process) {
789
883
  const { runFollow } = await import("./follow-cmd.js");
790
884
  return runFollow(argv.slice(1), io);
791
885
  }
886
+ // `dexterm update [--off|--on]` → is there a newer version, and the exact upgrade command for this install (task-1397).
887
+ // Two anonymous GETs (npm registry, GitHub releases); never upgrades anything itself.
888
+ if (argv[0] === 'update') {
889
+ const { updateCommand } = await import("./update.js");
890
+ return updateCommand(argv.slice(1), io);
891
+ }
892
+ // `dexterm voice [status|download <model>]` → inspect the voice engines and download the optional
893
+ // voice models (whisper base.en, Piper voice). Downloads ask first and are checksum-verified (task-1408).
894
+ if (argv[0] === 'voice') {
895
+ const { voiceCommand } = await import("./voice-cmd.js");
896
+ return voiceCommand(argv.slice(1), { stdin: io.stdin, stdout: io.stdout, stderr: io.stderr, env: io.env });
897
+ }
792
898
  // `dexterm doctor` → a pre-flight that says whether dexterm will run here and how to fix what won't (task-1305).
793
899
  if (argv[0] === 'doctor') {
794
900
  const { defaultDeps, runDoctor, renderDoctor } = await import("./doctor.js");
@@ -838,7 +944,7 @@ export async function main(argv, io = process, proc = process) {
838
944
  return 0;
839
945
  }
840
946
  if (args.version) {
841
- io.stdout.write('0.1.1\n');
947
+ io.stdout.write(`${VERSION}\n`);
842
948
  return 0;
843
949
  }
844
950
  if (args.unknown.length) {
@@ -932,6 +1038,8 @@ export async function main(argv, io = process, proc = process) {
932
1038
  feed.extraMints = () => [...liveCtx.newpairs.rows.keys()];
933
1039
  applyScreenerArgs(state, args);
934
1040
  if (args.mint) {
1041
+ if (args.mintVia === 'dexscreener')
1042
+ args.mint = (await resolveCoinTarget({ address: args.mint, via: 'dexscreener', rest: '' }, ctx.dl)).mint;
935
1043
  ctx.openToken({ mint: args.mint, symbol: args.mint.slice(0, 4) + '…', name: '', launchpad: 'unknown' });
936
1044
  if (args.screen === null)
937
1045
  state.screen = 'token';
@@ -1063,7 +1171,13 @@ export async function main(argv, io = process, proc = process) {
1063
1171
  }
1064
1172
  const c = renderFrame(state, w, h);
1065
1173
  ctx?.newpairs.markDrawn();
1066
- if (args.html) {
1174
+ if (args.export !== null) {
1175
+ // task-1401: `--export [DIR]` writes the same PNG + text report the `E` key does (default ~/.dexterm/exports).
1176
+ const now = Date.now();
1177
+ const r = writeExport(exportViewFor(state, w, h, now), { now, env: io.env, dir: args.export || undefined });
1178
+ io.stderr.write(`wrote ${r.png} (${r.bytes} bytes) and ${r.txt}\n`);
1179
+ }
1180
+ else if (args.html) {
1067
1181
  // task-1326: `--html … --color 256` renders through the xterm palette (a Terminal.app-faithful proof)
1068
1182
  writeFileSync(args.html, c.toHtml({ title: args.title || `dexterm — ${args.screen} — ${state.theme.name}`, fit: args.fit, fontPx: args.fontPx ?? 15, depth: args.color ?? 'truecolor' }));
1069
1183
  io.stderr.write(`wrote ${args.html} (${w}×${h})\n`);
@@ -1087,7 +1201,20 @@ export async function main(argv, io = process, proc = process) {
1087
1201
  feed?.close();
1088
1202
  return 0;
1089
1203
  }
1090
- return interactive(state, io, depth, args, proc);
1204
+ // task-1397: live interactive runs only: the cached "new version" notice (no network) plus, at most once a day,
1205
+ // a background refresh that never blocks the start. Off with DEXTERM_NO_UPDATE_CHECK=1 / `dexterm update --off`.
1206
+ const update = live ? (await import("./update.js")).startupCheck(io.env) : null;
1207
+ if (update) {
1208
+ state.updateNotice = update.notice;
1209
+ void update.refresh?.then((n) => { if (n)
1210
+ state.updateNotice = n; });
1211
+ }
1212
+ try {
1213
+ return await interactive(state, io, depth, args, proc);
1214
+ }
1215
+ finally {
1216
+ update?.cancel();
1217
+ }
1091
1218
  }
1092
1219
  const SCREEN_ORDER = ['screener', 'newpairs', 'watchlist', 'analyst'];
1093
1220
  /** Rows PgUp/PgDn jump through the list (task-1303). */
@@ -1164,6 +1291,10 @@ function followsMove(state, delta) {
1164
1291
  return false;
1165
1292
  if (state.copyUi.input !== null || state.copyUi.mirror || state.copyUi.autoModal)
1166
1293
  return true;
1294
+ if (state.copyUi.detail) {
1295
+ state.copyUi.detail.scroll = Math.max(0, state.copyUi.detail.scroll + (delta > 0 ? 1 : -1));
1296
+ return true;
1297
+ } // task-1399: j/k scroll the detail view
1167
1298
  const n = state.copy?.follows().length ?? 0;
1168
1299
  if (n <= 0) {
1169
1300
  state.copyUi.selected = 0;
@@ -1449,6 +1580,10 @@ export function applyAction(state, action, stack) {
1449
1580
  state.copyUi.notice = 'mirror preview closed';
1450
1581
  break;
1451
1582
  } // task-1360: esc closes the mirror preview first
1583
+ if (state.screen === 'follows' && state.copyUi?.detail) {
1584
+ state.copyUi.detail = null;
1585
+ break;
1586
+ } // task-1399: esc closes the wallet detail view before leaving the screen
1452
1587
  const prev = stack.pop();
1453
1588
  if (prev)
1454
1589
  state.screen = prev;
@@ -2269,6 +2404,14 @@ export function applyAction(state, action, stack) {
2269
2404
  case 'goto.open':
2270
2405
  state.goto = { input: '', notice: null };
2271
2406
  break;
2407
+ // task-1401 EXPORT: the loop renders the current view calm and writes the PNG + report (local files only).
2408
+ case 'export.view':
2409
+ state.pendingExport = true;
2410
+ break;
2411
+ // task-1407 PNL CARD: the loop opens the preview (recorded fills only) and runs the share keys.
2412
+ case 'pnl.card':
2413
+ state.pendingPnl = true;
2414
+ break;
2272
2415
  case 'display.priceMode':
2273
2416
  state.pendingToast = togglePriceMode(state);
2274
2417
  break; // task-1366: display only, persisted
@@ -2518,31 +2661,81 @@ function seedForMint(state, live, mint, symbol) {
2518
2661
  // task-1388 GO-TO prompt: the longest input we keep (a bare base58 mint is ≤ 44; the longest coin URL we
2519
2662
  // accept is well under this), so a huge paste can never balloon the buffer.
2520
2663
  const GOTO_INPUT_MAX = 120;
2521
- /** A full base58 Solana mint: 32–44 chars, no 0/O/I/l. Same shape `--mint` validates (main.ts parseArgs). */
2522
- const GOTO_MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
2664
+ // task-1388's parser now lives in coin-target.ts (shared with the analyst chat, the filter line and --mint).
2665
+ export { parseMintTarget };
2523
2666
  /**
2524
- * task-1388: turn a pasted/typed go-to target into a mint, or null when there is none. Accepts a bare base58
2525
- * mint, or a dexscreener / pump.fun / solscan URL (or any text) by pulling the first base58 run of mint length.
2526
- * Base58 excludes 0, O, I and l, so splitting on every non-base58 character also splits on them and on the URL
2527
- * punctuation (/, :, ., ?, =, &), leaving each path/query segment as its own token; a real mint is never cut.
2528
- * Pure and total: it only ever returns a validated mint string or null, and never throws. Exported for tests.
2667
+ * task-1401: open a pasted coin target (a CA, or a dexscreener / pump.fun / solscan / birdeye link) on the LIVE token
2668
+ * screen — the same read-only path `--screen token --mint` and the analyst's open_token use. A mint opens at once; a
2669
+ * dexscreener link is first resolved to its coin (its address is usually the pool) and opens when that answers.
2670
+ * `onOpened` runs after the open either way (the chat uses it to ask its question about the coin). Returns a notice
2671
+ * when it cannot open (fixtures mode), else null. Never trades, never signs.
2529
2672
  */
2530
- export function parseMintTarget(raw) {
2531
- const s = raw.trim();
2532
- if (!s)
2673
+ export function openCoinTarget(state, stack, t, onOpened) {
2674
+ const live = state.live && state.screener.source !== 'fixtures' ? state.live : null;
2675
+ if (!live)
2676
+ return 'opening a coin by address needs live mode (not --fixtures)';
2677
+ const open = (mint) => {
2678
+ if (!(state.screen === 'token' && live.token?.view.mint === mint))
2679
+ live.openToken(seedForMint(state, live, mint)); // already open: keep its loaded view
2680
+ if (state.screen !== 'token') {
2681
+ stack.push(state.screen);
2682
+ state.screen = 'token';
2683
+ }
2684
+ onOpened?.(mint);
2685
+ };
2686
+ if (t.via !== 'dexscreener') {
2687
+ open(t.address);
2533
2688
  return null;
2534
- if (GOTO_MINT_RE.test(s))
2535
- return s;
2536
- for (const tok of s.split(/[^1-9A-HJ-NP-Za-km-z]+/))
2537
- if (GOTO_MINT_RE.test(tok))
2538
- return tok;
2689
+ }
2690
+ live.notice = 'resolving the dexscreener link…';
2691
+ void resolveCoinTarget(t, live.dl).then(({ mint, fromPair }) => {
2692
+ if (live.isClosed)
2693
+ return;
2694
+ live.notice = fromPair ? `dexscreener pool → coin ${mint.slice(0, 4)}…${mint.slice(-4)}` : null;
2695
+ open(mint);
2696
+ state.aiFocused = false;
2697
+ syncAnalyst(state);
2698
+ live.onChange('goto');
2699
+ });
2539
2700
  return null;
2540
2701
  }
2702
+ /**
2703
+ * task-1401: the analyst chat's ⏎. When the typed text names a coin (a CA or a coin link), open that coin's token
2704
+ * view and, if a question came with it ("is <CA> a rug?"), ask it about THAT coin; returns true when it handled
2705
+ * the input. Plain questions return false and go to the analyst as before.
2706
+ */
2707
+ export function chatOpenCoin(state, stack, q, hooks) {
2708
+ const ct = q ? parseCoinTarget(q) : null;
2709
+ if (!ct)
2710
+ return false;
2711
+ state.aiInput = '';
2712
+ const question = isBareOpen(ct.rest) ? '' : ct.rest;
2713
+ const notice = openCoinTarget(state, stack, ct, (mint) => {
2714
+ syncAnalyst(state);
2715
+ if (question) {
2716
+ state.aiOpen = true;
2717
+ try {
2718
+ hooks.ask(question);
2719
+ }
2720
+ catch (e) {
2721
+ state.screener.notice = `⚠ ask failed: ${describeError(e)}`;
2722
+ }
2723
+ }
2724
+ else {
2725
+ state.aiFocused = false;
2726
+ hooks.say(`opened ${mint.slice(0, 4)}…${mint.slice(-4)} from the chat`, 'info', 4);
2727
+ }
2728
+ });
2729
+ if (notice)
2730
+ hooks.say(notice, 'warn', 5);
2731
+ return true;
2732
+ }
2541
2733
  /**
2542
2734
  * task-1388: resolve the GO-TO prompt's input to a coin and open its live token screen (read-only — the same
2543
2735
  * path `--screen token --mint` and the analyst's open_token use: live.openToken(seedForMint(...)), which seeds
2544
2736
  * an unknown-launchpad stub when the mint is not already on a screen). Bad input keeps the prompt open with a
2545
2737
  * one-line notice and changes nothing; a good open closes the prompt and pushes the current screen on the stack.
2738
+ * task-1401: a dexscreener link is resolved pool → coin first (openCoinTarget).
2546
2739
  */
2547
2740
  function openGotoTarget(state, stack) {
2548
2741
  const g = state.goto;
@@ -2558,17 +2751,13 @@ function openGotoTarget(state, stack) {
2558
2751
  g.notice = 'not a contract address or a dexscreener / pump.fun / solscan link';
2559
2752
  return;
2560
2753
  }
2561
- const live = state.live && state.screener.source !== 'fixtures' ? state.live : null;
2562
- if (!live) {
2563
- g.notice = 'opening a coin by address needs live mode (not --fixtures)';
2754
+ const t = parseCoinTarget(raw) ?? { address: mint, via: 'address', rest: '' };
2755
+ const notice = openCoinTarget(state, stack, t);
2756
+ if (notice) {
2757
+ g.notice = notice;
2564
2758
  return;
2565
2759
  }
2566
- live.openToken(seedForMint(state, live, mint));
2567
2760
  state.goto = null;
2568
- if (state.screen !== 'token') {
2569
- stack.push(state.screen);
2570
- state.screen = 'token';
2571
- }
2572
2761
  }
2573
2762
  /**
2574
2763
  * Apply one analyst-requested UI action to the state (task-1313). This is the trust boundary: every
@@ -2594,6 +2783,12 @@ export function applyAnalystAction(state, raw, stack) {
2594
2783
  if (!res.ok)
2595
2784
  return { ok: false, error: res.error };
2596
2785
  const action = res.action;
2786
+ // task-1413 tier (b): a persisted-config action is never applied by the model — return it as a PROPOSAL
2787
+ // the user accepts with a keypress (applyAnalystConfig then journals it as actor 'analyst').
2788
+ if (ACTION_TIER[action.type] === 'config') {
2789
+ times.push(now);
2790
+ return { ok: true, label: res.label, proposal: { action, summary: res.label } };
2791
+ }
2597
2792
  // Resolve a mint to a row object from what is on screen (allow-list guarantees it was visible).
2598
2793
  const findSeed = (mint) => seedForMint(state, live, mint);
2599
2794
  const toScreen = (next) => { if (state.screen !== next) {
@@ -2633,6 +2828,10 @@ export function applyAnalystAction(state, raw, stack) {
2633
2828
  }
2634
2829
  case 'show_in_screener': {
2635
2830
  toScreen('screener');
2831
+ if (action.preset && !setPreset(sc, action.preset))
2832
+ return { ok: false, error: `preset "${action.preset}" is unavailable` };
2833
+ if (action.preset && state.feed)
2834
+ state.feed.setPreset(sc.preset);
2636
2835
  if (action.filter) {
2637
2836
  const err = setFilter(sc, action.filter);
2638
2837
  if (err)
@@ -2646,12 +2845,101 @@ export function applyAnalystAction(state, raw, stack) {
2646
2845
  state.selected = i;
2647
2846
  }
2648
2847
  state.selected = clampSelection(sc, state.selected);
2848
+ if (action.preset) {
2849
+ times.push(now);
2850
+ return { ok: true, label: res.label, refresh: true };
2851
+ }
2852
+ break;
2853
+ }
2854
+ // --- task-1413 view-only actions: applied immediately, shown as "analyst did X".
2855
+ case 'switch_screen': {
2856
+ toScreen(action.screen);
2857
+ state.selected = 0;
2858
+ break;
2859
+ }
2860
+ case 'switch_workspace': {
2861
+ const r = switchWorkspace(state, action.name, stack);
2862
+ if (!r.ok)
2863
+ return { ok: false, error: r.reason };
2864
+ times.push(now);
2865
+ return { ok: true, label: res.label, refresh: r.refresh };
2866
+ }
2867
+ case 'chart_popup': {
2868
+ // Open the chart popup (the loop performs the open/close); needs a selected coin to chart.
2869
+ state.pendingPopup = action.open ? 'chart' : null;
2870
+ break;
2871
+ }
2872
+ case 'toggle_calm': {
2873
+ if (action.on) {
2874
+ if (!isCalm(state.vfx)) {
2875
+ state.vfx.intensity = 'off';
2876
+ state.vfx.trail.clear();
2877
+ }
2878
+ }
2879
+ else if (isCalm(state.vfx)) {
2880
+ const back = 'fever';
2881
+ state.vfx.intensity = back;
2882
+ state.vfx.baseline = 0.12;
2883
+ }
2884
+ break;
2885
+ }
2886
+ case 'set_scan': {
2887
+ scanCommand(state, action.on ? 'on' : 'off', stack);
2888
+ break;
2889
+ }
2890
+ case 'export_view': {
2891
+ state.pendingExport = true;
2649
2892
  break;
2650
2893
  }
2651
2894
  }
2652
2895
  times.push(now);
2653
2896
  return { ok: true, label: res.label };
2654
2897
  }
2898
+ /**
2899
+ * task-1413: apply an accepted tier-(b) config proposal through the journaled path as actor `analyst`
2900
+ * (so `ctrl+z` undoes it), creating the parent section when absent (exactly like the `$` / `,` handlers),
2901
+ * then reflect it in the live UI where cheap. Throws on a refusal (schema / policy); the caller toasts it.
2902
+ * Returns the one-line label for the confirmation toast.
2903
+ */
2904
+ export function applyAnalystConfig(state, proposal) {
2905
+ const ui = state.custom;
2906
+ if (!ui)
2907
+ throw new Error('config changes need a loaded config (not available on this run)');
2908
+ const change = configChangeForAction(proposal.action);
2909
+ if (!change)
2910
+ throw new Error('not a persisted-config action');
2911
+ const parent = ui.config[change.section];
2912
+ const op = parent && typeof parent === 'object'
2913
+ ? { op: 'add', path: change.path, value: change.value }
2914
+ : { op: 'add', path: `/${change.section}`, value: { [change.key]: change.value } };
2915
+ // THE GATE (task-1413 r2): re-run the model-actor policy on the exact journal op BEFORE writing. The
2916
+ // analyst is not the user, so `DENIED_FOR_MODELS` applies — a config action can never reach a signer /
2917
+ // trading / keys / caps / mandates / wallet / alerts / VOICE path, even behind the keypress confirm.
2918
+ // CONFIG_ACTION_PATHS is already a closed non-denied allow-list; this is the server-side belt-and-braces.
2919
+ const denied = policyCheck([op], 'claude');
2920
+ if (denied.length)
2921
+ throw new Error(`refused: ${denied.map((e) => e.msg).join('; ')}`);
2922
+ const { config } = applyAndJournal(ui.home, [op], { actor: 'analyst', why: `analyst: ${proposal.summary}`, now: state.now.getTime() });
2923
+ ui.config = config;
2924
+ // Reflect the change in the running UI where it is cheap and local.
2925
+ switch (proposal.action.type) {
2926
+ case 'set_theme': {
2927
+ const t = themeByName(ui, proposal.action.name);
2928
+ if (t)
2929
+ state.theme = t;
2930
+ break;
2931
+ }
2932
+ case 'set_price_mode':
2933
+ state.priceMode = proposal.action.mode;
2934
+ break;
2935
+ case 'set_intensity':
2936
+ state.vfx.intensity = proposal.action.intensity;
2937
+ state.vfx.baseline = proposal.action.intensity === 'fever' ? 0.12 : 0;
2938
+ break;
2939
+ default: break;
2940
+ }
2941
+ return proposal.summary;
2942
+ }
2655
2943
  /**
2656
2944
  * Route one decoded key: the screener's filter editor and sort menu swallow keys while open,
2657
2945
  * everything else goes through the keymap. Exported for tests.
@@ -2879,6 +3167,22 @@ export function applyKey(state, key, stack) {
2879
3167
  ui.input = cur + key.ch;
2880
3168
  return;
2881
3169
  }
3170
+ // task-1399: on the follows screen (not typing, no modal), enter opens/closes the selected wallet's DETAIL view.
3171
+ if (state.screen === 'follows' && state.copyUi && state.copyUi.input === null && !state.copyUi.mirror && !state.copyUi.autoModal && key.name === 'enter') {
3172
+ const ui = state.copyUi;
3173
+ if (ui.detail) {
3174
+ ui.detail = null;
3175
+ return;
3176
+ }
3177
+ const svc = state.copy;
3178
+ const rows = svc?.follows() ?? [];
3179
+ const f = rows[Math.max(0, Math.min(rows.length - 1, ui.selected))];
3180
+ if (f)
3181
+ ui.detail = { wallet: f.addr, scroll: 0 };
3182
+ else
3183
+ ui.notice = 'follow a wallet first (i)';
3184
+ return;
3185
+ }
2882
3186
  if (state.screen === 'kol' && state.kolInput !== null && state.kolInput !== undefined) {
2883
3187
  const kol = kolOf(state);
2884
3188
  if (key.name === 'esc') {
@@ -2918,6 +3222,18 @@ export function applyKey(state, key, stack) {
2918
3222
  state.pendingToast = typeof cmd === 'object' ? cmd.error : scanCommand(state, cmd, stack);
2919
3223
  return;
2920
3224
  }
3225
+ // task-1401: a pasted CA or coin link on the `/` line opens that coin (no filter field is a 32-44 char base58
3226
+ // run); the editor closes and the previous filter stays applied.
3227
+ const ct = parseCoinTarget(sc.edit.text);
3228
+ if (ct && isBareOpen(ct.rest)) {
3229
+ sc.mode = 'table';
3230
+ sc.edit.text = sc.query ?? '';
3231
+ sc.edit.error = null;
3232
+ const notice = openCoinTarget(state, stack, ct);
3233
+ if (notice)
3234
+ state.pendingToast = notice;
3235
+ return;
3236
+ }
2921
3237
  // task-1328: `/wallet …` is a command too (the loop performs it: link opens a browser, probe opens a ticket).
2922
3238
  const wc = parseWalletCommand(sc.edit.text);
2923
3239
  if (wc) {
@@ -3497,6 +3813,10 @@ export function interactive(state, io, depth, args, proc = process) {
3497
3813
  // Live bindings redraw as soon as something lands (one frame per macrotask at most).
3498
3814
  let drawPending = false;
3499
3815
  let lastFastDraw = 0;
3816
+ // task-1399: a followed wallet's buy/sell also PUSHES through the alerts engine (desktop / Telegram / Discord)
3817
+ // when the user enabled a push channel. Forward-declared; the real impl is set once the Notifier exists below.
3818
+ // Read-only, like a rule alert — nothing here trades, arms or signs.
3819
+ let pushCopyAlert = () => { };
3500
3820
  if (live) {
3501
3821
  // task-1360: poll the followed wallets for the whole run (live only; replay never spends credits) and toast
3502
3822
  // every new buy/sell a followed wallet made. Information only: no path from an alert to a trade exists here.
@@ -3509,6 +3829,8 @@ export function interactive(state, io, depth, args, proc = process) {
3509
3829
  const a = as[as.length - 1];
3510
3830
  const who = a.name ?? shortAddr(a.wallet);
3511
3831
  say(`${who} ${a.trade.side === 'buy' ? 'BOUGHT' : 'SOLD'} ${shortAddr(a.trade.mint)}${a.trade.usd !== null ? ` ≈ $${a.trade.usd.toFixed(0)}` : ''}${a.lagMs !== null ? ` · lag ${Math.round(a.lagMs / 1000)}s` : ''}${as.length > 1 ? ` (+${as.length - 1} more · U)` : ' (U)'}`, a.trade.side === 'buy' ? 'up' : 'down', 6);
3832
+ for (const ca of as)
3833
+ pushCopyAlert(ca); // task-1399: push each one out through the alerts engine too
3512
3834
  }
3513
3835
  }
3514
3836
  if (done || drawPending)
@@ -3629,6 +3951,15 @@ export function interactive(state, io, depth, args, proc = process) {
3629
3951
  const r = applyAnalystAction(state, raw, stack);
3630
3952
  if (!r.ok || !r.label)
3631
3953
  return;
3954
+ // task-1413 tier (b): a persisted-config request is a PROPOSAL — show it and wait for the user's key.
3955
+ if (r.proposal) {
3956
+ // The proposal expires with its 8s toast (task-1413 defect c): a 'y' typed after it fades never
3957
+ // persists a stale change. `now` is the frame clock so it stays deterministic under replay.
3958
+ state.analystConfigProposal = { ...r.proposal, expiresAt: state.now.getTime() + 8000 };
3959
+ say(`analyst suggests: ${r.proposal.summary} — press y to accept, any other key to dismiss`, 'ai', 8);
3960
+ scheduleDraw();
3961
+ return;
3962
+ }
3632
3963
  const log = (state.analystActions ??= []);
3633
3964
  log.push(r.label);
3634
3965
  if (log.length > 6)
@@ -4099,6 +4430,28 @@ export function interactive(state, io, depth, args, proc = process) {
4099
4430
  say(`skipped ${plan.skipped.map((s) => `${s.kind} (${s.why})`).join(', ')}`, 'info', 5);
4100
4431
  scheduleDraw();
4101
4432
  };
4433
+ // task-1398: PUSH delivery while the TUI is open. Built only when the user enabled a push channel
4434
+ // (desktop/telegram/discord); otherwise null and there is no behaviour change. The in-app toast is
4435
+ // still state.pendingToast, so the Notifier's toast sink is a no-op here (routing may still include it,
4436
+ // harmlessly). Delivery is fire-and-forget: a slow channel never blocks the frame.
4437
+ const alertNotifier = state.custom && hasPushChannel(state.custom.config.alerts)
4438
+ ? buildNotifier(state.custom.home, state.custom.config.alerts, { toastSink: () => { } })
4439
+ : null;
4440
+ // task-1399: now the Notifier exists, a followed wallet's buy/sell can be pushed. Fire-and-forget; a slow
4441
+ // channel never blocks the loop; routing's `levels` filter lets the user scope (a buy is `up`, a sell `down`).
4442
+ if (alertNotifier)
4443
+ pushCopyAlert = (ca) => { void alertNotifier.deliver(copyAlertToDelivery(ca)).catch(() => { }); };
4444
+ const drainDeliveries = () => {
4445
+ const ui = state.rulesUi;
4446
+ if (!ui || !ui.pendingDeliveries.length)
4447
+ return;
4448
+ const ds = ui.pendingDeliveries;
4449
+ ui.pendingDeliveries = [];
4450
+ if (!alertNotifier)
4451
+ return;
4452
+ for (const d of ds)
4453
+ void alertNotifier.deliver(d).catch(() => { });
4454
+ };
4102
4455
  // task-1358 Tier 3: perform what the rules engine / an accepted proposal queued. A ticket proposal opens the
4103
4456
  // PAPER ticket in its editing phase (the user sizes + confirms there; signer caps apply) — never a live ticket,
4104
4457
  // never a confirm. An ask proposal is a question to the analyst. Popups are the same read-only windows the keys
@@ -4258,6 +4611,47 @@ export function interactive(state, io, depth, args, proc = process) {
4258
4611
  return { price: np.price ?? null, mcap: np.mcap ?? null };
4259
4612
  return { price: null, mcap: null };
4260
4613
  };
4614
+ // task-1411: a short instruction appended to a VOICE analyst question so the answer is built for the
4615
+ // ear, not the screen — market cap not a per-token price, round numbers, short.
4616
+ const VOICE_ANALYST_SPEECH_HINT = 'Answer out loud for text-to-speech: talk about price as market cap, never a raw per-token price; '
4617
+ + 'round big numbers like "340 thousand" or "1.2 million"; no long addresses; keep it to two or three short sentences.';
4618
+ // A spoken pronoun that points at the highlighted row ("this one", "this coin", "it").
4619
+ const VOICE_SELECTION_PRONOUN = /\b(?:this (?:one|coin|token)|that (?:one|coin|token)|this|it)\b/i;
4620
+ // A friendly, complete spoken ack for each navigable screen (the router's `say` is the terse key).
4621
+ const NAV_ACK = {
4622
+ screener: 'the screener', newpairs: 'new pairs', watchlist: 'your watchlist', analyst: 'the analyst', positions: 'your positions',
4623
+ };
4624
+ /**
4625
+ * Run a VOICE-originated analyst question (task-1411). Fixes the screener silence: "what do you think
4626
+ * of THIS ONE" aimed at a highlighted screener row used to start a context-free `trench-scan` with no
4627
+ * mint, so the analyst never learned which coin "this one" was and answered with a short, useless
4628
+ * fragment (the tester heard nothing useful). We now attach the SELECTED coin as a token-dossier target —
4629
+ * exactly the context a token screen already gives — so it answers for real. The question also carries
4630
+ * a short "answer for speech" instruction (market cap, round numbers, short).
4631
+ */
4632
+ const askVoiceAnalyst = (question) => {
4633
+ const q = question.trim().slice(0, 400);
4634
+ const instructed = `${q}\n\n${VOICE_ANALYST_SPEECH_HINT}`;
4635
+ // A warm session: follow up on it so the model keeps context (the screen is already analyst/token).
4636
+ if (state.analyst) {
4637
+ state.analyst.ask(instructed, buildScreenState(state));
4638
+ return;
4639
+ }
4640
+ const tv = live?.token?.view;
4641
+ const sel = voiceSelected();
4642
+ if (state.screen === 'token' && live && tv?.mint) {
4643
+ state.pendingAnalyst = { skill: 'token-dossier', mint: tv.mint, symbol: tv.symbol, target: 'token', question: instructed };
4644
+ }
4645
+ else if (live && sel && VOICE_SELECTION_PRONOUN.test(q)) {
4646
+ // the screener/analyst/watchlist/new-pairs highlighted coin IS "this one" (task-1411)
4647
+ state.pendingAnalyst = { skill: 'token-dossier', mint: sel.mint, symbol: sel.symbol, target: 'analyst', question: instructed };
4648
+ }
4649
+ else {
4650
+ state.pendingAnalyst = { skill: 'trench-scan', target: 'analyst', question: instructed };
4651
+ }
4652
+ if (state.pendingAnalyst)
4653
+ beginAnalyst();
4654
+ };
4261
4655
  /**
4262
4656
  * Perform a voice decision. Returns `false` when the app spoke its own reply (an ask / clarify), so
4263
4657
  * the controller does not read the decision's `say` line over it (task-1339, re-verify N1: the
@@ -4273,6 +4667,7 @@ export function interactive(state, io, depth, args, proc = process) {
4273
4667
  const a = SCREEN_ACTION[d.screen];
4274
4668
  if (a)
4275
4669
  applyAction(state, a, stack);
4670
+ ask(NAV_ACK[d.screen] ?? d.say);
4276
4671
  break;
4277
4672
  }
4278
4673
  case 'open-token': {
@@ -4397,14 +4792,14 @@ export function interactive(state, io, depth, args, proc = process) {
4397
4792
  case 'analyst': {
4398
4793
  // Route the question to the analyst, surface the chat, and let the answer be spoken when it lands.
4399
4794
  // Capped like typed chat (400 chars): a runaway transcript never becomes a 200 KB argv (N7).
4795
+ // task-1411: askVoiceAnalyst attaches the highlighted coin as context so a screener "this one"
4796
+ // question is answered for real, and appends the "answer for speech" instruction.
4400
4797
  if (state.screen !== 'token' && state.screen !== 'analyst') {
4401
4798
  stack.push(state.screen);
4402
4799
  state.screen = 'analyst';
4403
4800
  }
4404
4801
  syncAnalyst(state);
4405
- sendChat(d.question.slice(0, 400));
4406
- if (state.pendingAnalyst)
4407
- beginAnalyst();
4802
+ askVoiceAnalyst(d.question);
4408
4803
  break;
4409
4804
  }
4410
4805
  case 'workspace': {
@@ -4456,26 +4851,65 @@ export function interactive(state, io, depth, args, proc = process) {
4456
4851
  else
4457
4852
  voice.abortTurn(session.status === 'error' ? "Sorry — the analyst hit an error. Try again." : "Okay, stopped.");
4458
4853
  };
4854
+ // task-1393: persist the `,` voice choice to config.voice.name (actor 'user', journaled → undo works).
4855
+ // Best-effort: a persistence failure keeps the voice for this run and never breaks the UI.
4856
+ const persistVoiceChoice = (voiceName) => {
4857
+ const ui = state.custom;
4858
+ if (!ui)
4859
+ return;
4860
+ try {
4861
+ const op = ui.config.voice
4862
+ ? { op: 'add', path: '/voice/name', value: voiceName }
4863
+ : { op: 'add', path: '/voice', value: { name: voiceName } };
4864
+ const { config } = applyAndJournal(ui.home, [op], { actor: 'user', why: `, voice ${voiceName}`, now: state.now.getTime() });
4865
+ ui.config = config;
4866
+ }
4867
+ catch { /* keep the voice for this run; nothing to surface */ }
4868
+ };
4869
+ // task-1411: persist the < / > speaking-rate nudge to config.voice.rate (actor 'user', journaled →
4870
+ // undo works). Best-effort: a failure keeps the rate for this run and never breaks the UI.
4871
+ const persistVoiceRate = (rate) => {
4872
+ const ui = state.custom;
4873
+ if (!ui)
4874
+ return;
4875
+ try {
4876
+ const op = ui.config.voice
4877
+ ? { op: 'add', path: '/voice/rate', value: rate }
4878
+ : { op: 'add', path: '/voice', value: { rate } };
4879
+ const { config } = applyAndJournal(ui.home, [op], { actor: 'user', why: `voice rate ${rate}`, now: state.now.getTime() });
4880
+ ui.config = config;
4881
+ }
4882
+ catch { /* keep the rate for this run; nothing to surface */ }
4883
+ };
4459
4884
  const initVoice = async () => {
4460
4885
  if (voiceInitStarted)
4461
4886
  return;
4462
4887
  voiceInitStarted = true;
4463
4888
  state.voice = { enabled: true, mode: 'idle', muted: false, voice: null, transcript: null, say: null, message: 'preparing voice…' };
4464
4889
  scheduleDraw();
4890
+ // task-1393: the user's saved voice/rate (if any). A missing/banned name is resolved to a safe
4891
+ // deep British male by selectTts — it never falls back to the machine's system voice.
4892
+ const cfgVoice = state.custom?.config.voice;
4465
4893
  let stt = null, tts = null;
4466
4894
  try {
4467
- [stt, tts] = await Promise.all([selectStt(), selectTts()]);
4895
+ [stt, tts] = await Promise.all([selectStt(), selectTts(undefined, cfgVoice?.name ?? null)]);
4468
4896
  }
4469
4897
  catch { /* both null → degrade to text */ }
4470
4898
  if (done)
4471
4899
  return;
4900
+ // Seed the controller with the ENGINE's resolved voice so its cycle/status start on the British male,
4901
+ // not on voices[0] (which could be a novelty voice like "Albert").
4902
+ const resolvedVoice = tts instanceof SayTts ? tts.voiceName : undefined;
4903
+ const style = voiceStyleDefaults(resolvedVoice); // the picked voice's own rate/pitch, unless the user saved theirs
4472
4904
  voice = new VoiceController({
4473
- stt, tts, getScreenState: voiceScreenState,
4905
+ stt, tts, voice: resolvedVoice, rate: cfgVoice?.rate ?? style?.rate, pitch: cfgVoice?.pitch ?? style?.pitch, getScreenState: voiceScreenState,
4474
4906
  onState: () => { if (!done && voice) {
4475
4907
  state.voice = toHud();
4476
4908
  scheduleDraw();
4477
4909
  } },
4478
4910
  onDecision: handleVoiceDecision,
4911
+ onVoiceChange: persistVoiceChoice,
4912
+ onRateChange: persistVoiceRate,
4479
4913
  });
4480
4914
  voice.enable();
4481
4915
  await voice.toggleTalk(); // the first `v` press both enables and starts listening
@@ -4539,6 +4973,35 @@ export function interactive(state, io, depth, args, proc = process) {
4539
4973
  quit();
4540
4974
  return;
4541
4975
  }
4976
+ // task-1413: a pending analyst config PROPOSAL owns the next key. `y` accepts and journals it as
4977
+ // actor 'analyst' (ctrl+z undoes); any other key dismisses it. A stray key never persists a change.
4978
+ // Two guards (defect c): the proposal does NOT steal a key while a TEXT INPUT is open — the GO-TO
4979
+ // prompt or a focused analyst chat line — so a 'y' being typed goes into the field, not the accept;
4980
+ // and an EXPIRED proposal (its 8s toast has faded) is silently dropped, never accepted by a late 'y'.
4981
+ if (state.analystConfigProposal) {
4982
+ const inputOpen = !!state.goto || (state.aiFocused === true);
4983
+ const expired = state.now.getTime() >= state.analystConfigProposal.expiresAt;
4984
+ if (expired) {
4985
+ state.analystConfigProposal = null; // let the key fall through to its normal handler below
4986
+ }
4987
+ else if (!inputOpen) {
4988
+ const prop = state.analystConfigProposal;
4989
+ state.analystConfigProposal = null;
4990
+ if (key.ch === 'y' || key.ch === 'Y') {
4991
+ try {
4992
+ say(`analyst ${applyAnalystConfig(state, prop)} · ctrl+z undoes`, 'ai', 5);
4993
+ }
4994
+ catch (e) {
4995
+ say(`change refused: ${describeError(e)}`, 'warn', 4);
4996
+ }
4997
+ }
4998
+ else {
4999
+ say('dismissed the analyst change', 'ai', 3);
5000
+ }
5001
+ draw();
5002
+ continue;
5003
+ }
5004
+ }
4542
5005
  // task-1388 GO-TO prompt: while the "open any coin" input is open it owns the keyboard. Route the key
4543
5006
  // straight to applyKey (which does the capture / open) and skip the voice, calm, demo and keymap
4544
5007
  // handlers below — otherwise a base58 char like v / m / c would toggle voice or calm instead of typing.
@@ -4573,7 +5036,7 @@ export function interactive(state, io, depth, args, proc = process) {
4573
5036
  // `micEngaged` also covers the window while the mic is still OPENING (listen() in flight), so a key
4574
5037
  // pressed before the engine came up closes the session the moment it arrives (task-1339, N3/N8).
4575
5038
  if (voice?.micEngaged) {
4576
- const isVoiceCtl = key.name === 'char' && !key.ctrl && !key.alt && (key.ch === 'v' || key.ch === 'm' || key.ch === ',');
5039
+ const isVoiceCtl = key.name === 'char' && !key.ctrl && !key.alt && (key.ch === 'v' || key.ch === 'm' || key.ch === ',' || key.ch === '<' || key.ch === '>');
4577
5040
  if (key.name === 'esc') {
4578
5041
  voice.cancelListen();
4579
5042
  draw();
@@ -4615,6 +5078,16 @@ export function interactive(state, io, depth, args, proc = process) {
4615
5078
  draw();
4616
5079
  continue;
4617
5080
  }
5081
+ if (voice?.isEnabled && key.ch === '<') {
5082
+ voice.nudgeRate(-1);
5083
+ draw();
5084
+ continue;
5085
+ } // slower (task-1411)
5086
+ if (voice?.isEnabled && key.ch === '>') {
5087
+ voice.nudgeRate(1);
5088
+ draw();
5089
+ continue;
5090
+ } // faster (task-1411)
4618
5091
  }
4619
5092
  }
4620
5093
  // A modal overlay (welcome/help) is open: any key dismisses it; `?` switches welcome→help (task-1305).
@@ -4633,6 +5106,32 @@ export function interactive(state, io, depth, args, proc = process) {
4633
5106
  draw();
4634
5107
  continue;
4635
5108
  }
5109
+ // task-1407: the PnL card preview owns the keys while open (share / save / toggles / other coin / esc).
5110
+ if (state.overlay === 'pnl' && state.pnl) {
5111
+ const ui = state.pnl;
5112
+ const eff = pnlKey(ui, key);
5113
+ if (eff.kind === 'close') {
5114
+ state.overlay = null;
5115
+ state.pnl = null;
5116
+ }
5117
+ else if (eff.kind === 'reload') {
5118
+ ui.stats = null;
5119
+ ui.error = null;
5120
+ void loadPnl(state).then(() => { if (!done)
5121
+ draw(); });
5122
+ }
5123
+ else if (eff.kind === 'share' && ui.stats) {
5124
+ ui.busy = true;
5125
+ ui.notice = null;
5126
+ void shareCard(ui.stats, { ...ui.opts }, eff.action, { platform: process.platform, env: io.env, run: runLocal, openUrl: (u) => openInBrowser(u), copyText: copyToClipboard })
5127
+ .then((r) => { ui.notice = r.message; say(r.message, 'info', 10); })
5128
+ .catch((e) => { ui.notice = `card failed: ${describeError(e)}`; })
5129
+ .finally(() => { ui.busy = false; if (!done)
5130
+ draw(); });
5131
+ }
5132
+ draw();
5133
+ continue;
5134
+ }
4636
5135
  // task-1358: the WORKSPACES card — a digit picks a slot, j/k move, ⏎ activates, esc/q/ctrl+w w close.
4637
5136
  if (state.overlay === 'workspaces' && state.custom) {
4638
5137
  const ui = state.custom;
@@ -5016,6 +5515,21 @@ export function interactive(state, io, depth, args, proc = process) {
5016
5515
  draw();
5017
5516
  continue;
5018
5517
  }
5518
+ // task-1401: a CA or a dexscreener / pump.fun / solscan / birdeye link in the chat opens that coin's token
5519
+ // view (read-only, the go-to path); any question typed with it is then asked about THAT coin.
5520
+ const before = state.screen;
5521
+ if (chatOpenCoin(state, stack, q, { ask: sendChat, say })) {
5522
+ if (state.screen !== before) {
5523
+ try {
5524
+ startTransition();
5525
+ }
5526
+ catch {
5527
+ transition = null;
5528
+ }
5529
+ }
5530
+ draw();
5531
+ continue;
5532
+ }
5019
5533
  if (q) {
5020
5534
  state.aiInput = '';
5021
5535
  try {
@@ -5254,6 +5768,29 @@ export function interactive(state, io, depth, args, proc = process) {
5254
5768
  copyToClipboard(m);
5255
5769
  say(`copied CA ${m.slice(0, 4)}…${m.slice(-4)}`, 'info');
5256
5770
  }
5771
+ if (state.pendingExport) {
5772
+ // task-1401: one key → PNG + text report of the current view in ~/.dexterm/exports. Nothing is uploaded.
5773
+ state.pendingExport = false;
5774
+ try {
5775
+ const [ew, eh] = size();
5776
+ const now = Date.now();
5777
+ const r = writeExport(exportViewFor(state, ew, eh, now), { now, env: io.env });
5778
+ say(`exported PNG + report → ${r.png.replace(homedir(), '~')} · local only, nothing uploaded`, 'info', 8);
5779
+ }
5780
+ catch (e) {
5781
+ say(`export failed: ${describeError(e)}`, 'warn', 6);
5782
+ }
5783
+ }
5784
+ if (state.pendingPnl) {
5785
+ // task-1407: open the PnL card preview for this screen (recorded fills only), then build the card.
5786
+ state.pendingPnl = false;
5787
+ const why = openPnl(state);
5788
+ if (why)
5789
+ say(why, 'warn', 6);
5790
+ else
5791
+ void loadPnl(state).then(() => { if (!done)
5792
+ draw(); });
5793
+ }
5257
5794
  if (state.pendingToast) {
5258
5795
  // task-1315: surface the link list / "no link" reply as a durable toast. Drained BEFORE
5259
5796
  // pendingOpen so that on a successful open the "opened <url>" toast overwrites the "open <url>" one.
@@ -5317,6 +5854,11 @@ export function interactive(state, io, depth, args, proc = process) {
5317
5854
  endBoot();
5318
5855
  }
5319
5856
  if (v.frame % tickEvery === 0) {
5857
+ // task-1397: the "new version available" toast, once, after the boot and never over the welcome card.
5858
+ if (state.updateNotice && state.screen !== 'boot' && !state.overlay) {
5859
+ say(state.updateNotice, 'info', 8);
5860
+ state.updateNotice = null;
5861
+ }
5320
5862
  if (feed) {
5321
5863
  if (!state.paused && Date.now() - lastRefresh >= LIVE_REFRESH_MS)
5322
5864
  refresh();
@@ -5328,6 +5870,9 @@ export function interactive(state, io, depth, args, proc = process) {
5328
5870
  lastTokenRefresh = Date.now();
5329
5871
  void live.token.load();
5330
5872
  }
5873
+ // task-1404: keep the open token's credit-metered holder/rug intel LIVE (slow cadence, budget-gated inside).
5874
+ if (state.screen === 'token' && live.token)
5875
+ live.refreshOpenIntel(Date.now());
5331
5876
  if (state.screen === 'watchlist' && Date.now() - lastWatchRefresh >= WATCH_REFRESH_MS) {
5332
5877
  lastWatchRefresh = Date.now();
5333
5878
  void live.watch.refresh();
@@ -5361,6 +5906,7 @@ export function interactive(state, io, depth, args, proc = process) {
5361
5906
  if (rulesTick(state, Date.now()))
5362
5907
  flusher.invalidate();
5363
5908
  drainRulesPending();
5909
+ drainDeliveries();
5364
5910
  }
5365
5911
  catch (e) {
5366
5912
  state.screener.notice = `⚠ rules tick failed: ${describeError(e)}`;
@@ -5391,6 +5937,7 @@ export function interactive(state, io, depth, args, proc = process) {
5391
5937
  if (rulesTick(state, Date.now()))
5392
5938
  flusher.invalidate();
5393
5939
  drainRulesPending();
5940
+ drainDeliveries();
5394
5941
  }
5395
5942
  catch (e) {
5396
5943
  state.screener.notice = `⚠ rules tick failed: ${describeError(e)}`;