traderclaw-cli 1.0.93 → 1.0.95-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { createInterface } from "readline";
4
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
+ import { spawnSync } from "child_process";
7
8
 
8
9
  const VERSION = "1.0.0";
9
10
  const PLUGIN_ID = "solana-trader";
@@ -611,6 +612,57 @@ async function cmdConfig(subArgs: string[]) {
611
612
  process.exit(1);
612
613
  }
613
614
 
615
+ async function cmdUpdate(args: string[]) {
616
+ const tag = args.includes("--beta") ? "beta" : "latest";
617
+
618
+ print("\nOpenClaw Trader — Update\n");
619
+ print("=".repeat(45));
620
+
621
+ let currentVersion = "unknown";
622
+ try {
623
+ const r = spawnSync("npm", ["list", "-g", "traderclaw-cli", "--json", "--depth=0"], { encoding: "utf-8" });
624
+ if (r.stdout) {
625
+ const data = JSON.parse(r.stdout) as { dependencies?: Record<string, { version?: string }> };
626
+ currentVersion = data?.dependencies?.["traderclaw-cli"]?.version ?? "unknown";
627
+ }
628
+ } catch {}
629
+ printInfo(` Current version: ${currentVersion}`);
630
+
631
+ let latestVersion = "unknown";
632
+ try {
633
+ const r = spawnSync("npm", ["view", `traderclaw-cli@${tag}`, "version"], { encoding: "utf-8" });
634
+ latestVersion = r.stdout?.trim() ?? "unknown";
635
+ } catch {}
636
+ printInfo(` Available (${tag}):${" ".repeat(Math.max(1, 9 - tag.length))}${latestVersion}`);
637
+
638
+ if (currentVersion !== "unknown" && latestVersion !== "unknown" && currentVersion === latestVersion) {
639
+ printSuccess(`\n Already on the ${tag} version (${currentVersion}). Nothing to do.\n`);
640
+ return;
641
+ }
642
+
643
+ print(`\n Installing traderclaw-cli@${tag}...\n`);
644
+
645
+ const install = spawnSync("npm", ["install", "-g", `traderclaw-cli@${tag}`], { stdio: "inherit" });
646
+ if (install.status !== 0) {
647
+ printError("npm install failed. Try running manually:");
648
+ print(` npm install -g traderclaw-cli@${tag}`);
649
+ process.exit(1);
650
+ }
651
+
652
+ printSuccess(`\n Package updated.`);
653
+ print("\n Restarting gateway...\n");
654
+
655
+ const restart = spawnSync("openclaw", ["gateway", "restart"], { stdio: "inherit" });
656
+ if (restart.status !== 0) {
657
+ printWarn(" Gateway restart returned non-zero. Restart manually: openclaw gateway restart");
658
+ } else {
659
+ printSuccess(" Gateway restarted.");
660
+ }
661
+
662
+ print("\n" + "=".repeat(45));
663
+ printSuccess("\n Update complete!\n");
664
+ }
665
+
614
666
  function printHelp() {
615
667
  print(`
616
668
  OpenClaw Solana Trader CLI v${VERSION}
@@ -621,6 +673,7 @@ Commands:
621
673
  setup Set up the plugin (API key, orchestrator, wallet)
622
674
  status Check connection health and wallet status
623
675
  config View and manage configuration
676
+ update Update to the latest version and restart the gateway
624
677
 
625
678
  Setup options:
626
679
  --api-key, -k API key (skip interactive prompt)
@@ -632,11 +685,16 @@ Config subcommands:
632
685
  config set <k> <v> Update a configuration value
633
686
  config reset Remove plugin configuration
634
687
 
688
+ Update options:
689
+ --beta Update to the latest beta version instead of stable
690
+
635
691
  Examples:
636
692
  openclaw-trader setup
637
693
  openclaw-trader setup --api-key sk_live_abc123 --url https://api.traderclaw.ai
638
694
  openclaw-trader setup --telegram-recipient @MyChannelOrUser
639
695
  openclaw-trader status
696
+ openclaw-trader update
697
+ openclaw-trader update --beta
640
698
  openclaw-trader config show
641
699
  openclaw-trader config set apiTimeout 60000
642
700
  `);
@@ -666,6 +724,9 @@ async function main() {
666
724
  case "config":
667
725
  await cmdConfig(args.slice(1));
668
726
  break;
727
+ case "update":
728
+ await cmdUpdate(args.slice(1));
729
+ break;
669
730
  default:
670
731
  printError(`Unknown command: ${command}`);
671
732
  printHelp();
@@ -2,6 +2,7 @@ import { execFileSync, execSync, spawn } from "child_process";
2
2
  import { randomBytes } from "crypto";
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
4
4
  import { homedir, tmpdir } from "os";
5
+ import * as os from "os";
5
6
  import { dirname, join } from "path";
6
7
  import { resolvePluginPackageRoot } from "./resolve-plugin-root.mjs";
7
8
  import { choosePreferredProviderModel } from "./llm-model-preference.mjs";
@@ -212,7 +213,11 @@ const NO_COLOR_ENV = { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" };
212
213
  * The current OpenClaw approach (docs.openclaw.ai/channels/telegram):
213
214
  * channels.telegram.botToken = "<token>" → token source
214
215
  * channels.telegram.enabled = true → enable the channel
215
- * channels.telegram.dmPolicy = "pairing" safe default (user approves first DM)
216
+ * channels.telegram.dmPolicy = "open" single-user wizard install: deliver DMs without pairing
217
+ *
218
+ * Note: "pairing" is safer for multi-tenant deployments but breaks the wizard onboarding flow
219
+ * (heartbeat fires before the user has paired, so the very first DM is dropped). Single-user
220
+ * wizard installs almost always want "open"; tighten later if needed.
216
221
  */
217
222
  function writeTelegramChannelConfig(botToken, configPath = CONFIG_FILE) {
218
223
  let config = {};
@@ -227,7 +232,7 @@ function writeTelegramChannelConfig(botToken, configPath = CONFIG_FILE) {
227
232
  config.channels.telegram.botToken = botToken;
228
233
  // Only set dmPolicy if not already configured (preserve existing policy on re-installs).
229
234
  if (!config.channels.telegram.dmPolicy) {
230
- config.channels.telegram.dmPolicy = "pairing";
235
+ config.channels.telegram.dmPolicy = "open";
231
236
  }
232
237
  ensureAgentsDefaultsSchemaCompat(config);
233
238
  mkdirSync(CONFIG_DIR, { recursive: true });
@@ -530,42 +535,6 @@ async function installOpenClawPlatform() {
530
535
  };
531
536
  }
532
537
 
533
- /**
534
- * Check whether the OpenClaw CLI has any devices stuck in a pending-approval or
535
- * repair state. This can happen when the gateway version >= 1.0.93-beta.0 starts
536
- * treating every CLI invocation as a "device" that must be explicitly approved
537
- * before it gets operator-write scope. Without that scope all trading RPCs fail
538
- * silently (read-only).
539
- *
540
- * Returns:
541
- * { ran: false } – openclaw not on PATH or devices subcommand not supported
542
- * { ran: true, pendingIds: string[], – list ran OK; ids needing approval
543
- * repairDetected: boolean, – current device is in repair/read-only state
544
- * envTokenSet: boolean } – OPENCLAW_GATEWAY_TOKEN env var already present (fallback)
545
- */
546
- function checkOpenClawDeviceApproval() {
547
- if (!commandExists("openclaw")) return { ran: false };
548
- const raw = getCommandOutput("openclaw devices list");
549
- if (!raw) return { ran: false };
550
-
551
- const lower = raw.toLowerCase();
552
- const envTokenSet = !!process.env.OPENCLAW_GATEWAY_TOKEN;
553
-
554
- // Detect devices that are waiting for approval ("pending" requestId lines).
555
- const pendingIds = [];
556
- for (const line of raw.split("\n")) {
557
- // Lines typically look like: d4fcdbe8-5176-422b-... pending
558
- const m = line.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
559
- if (m && (line.toLowerCase().includes("pending") || line.toLowerCase().includes("repair"))) {
560
- pendingIds.push(m[1]);
561
- }
562
- }
563
-
564
- const repairDetected = lower.includes("repair");
565
-
566
- return { ran: true, pendingIds, repairDetected, envTokenSet, raw };
567
- }
568
-
569
538
  function isNpmGlobalBinConflict(err, cliName) {
570
539
  const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
571
540
  return (
@@ -898,13 +867,46 @@ function mergePluginsAllowlist(modeConfig, configPath = CONFIG_FILE) {
898
867
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
899
868
  }
900
869
 
870
+ /**
871
+ * Resolve per-provider model strings used by managed cron jobs.
872
+ * Returns `{ heavy, light }`. `heavy` is the wizard-chosen model. `light` is a cheaper variant
873
+ * when the provider has one (Anthropic → Haiku); otherwise `light` falls back to `heavy`.
874
+ *
875
+ * This replaces the previous hardcoded `anthropic/claude-sonnet-4-20250514` and
876
+ * `anthropic/claude-haiku-4-5` cron models, which silently failed for Codex / OpenAI / etc.
877
+ * installs because the user never authenticated with Anthropic.
878
+ */
879
+ function resolveCronModels(provider, model) {
880
+ const heavy = (typeof model === "string" && model.startsWith(`${provider}/`)) ? model : null;
881
+ if (provider === "anthropic") {
882
+ return {
883
+ heavy: heavy || "anthropic/claude-sonnet-4-6",
884
+ light: "anthropic/claude-haiku-4-5",
885
+ };
886
+ }
887
+ if (provider === "openai-codex") {
888
+ const m = heavy || "openai-codex/gpt-5.4";
889
+ return { heavy: m, light: m };
890
+ }
891
+ if (provider === "openai") {
892
+ const m = heavy || "openai/gpt-5.4";
893
+ return { heavy: m, light: m };
894
+ }
895
+ // Generic fallback: same model for both tiers (configured provider determines the model string).
896
+ const fallback = heavy || `${provider}/default`;
897
+ return { heavy: fallback, light: fallback };
898
+ }
899
+
901
900
  /**
902
901
  * Managed cron jobs with prescriptive tool chains (VPS report 2026-03-24).
903
902
  * Schedules are staggered (minutes :00 / :15 / :30 / :45) where possible to avoid pile-ups.
904
903
  * @param {string} agentId
904
+ * @param {{ heavy: string, light: string }} models
905
905
  * @returns {Array<{ id: string, schedule: string, agentId: string, message: string, enabled: boolean }>}
906
906
  */
907
- function traderCronPrescriptiveJobs(agentId) {
907
+ function traderCronPrescriptiveJobs(agentId, models = { heavy: "anthropic/claude-sonnet-4-6", light: "anthropic/claude-haiku-4-5" }) {
908
+ const HEAVY = models.heavy;
909
+ const LIGHT = models.light;
908
910
  return [
909
911
  {
910
912
  id: "alpha-scan",
@@ -912,7 +914,7 @@ function traderCronPrescriptiveJobs(agentId) {
912
914
  agentId,
913
915
  message:
914
916
  "CRON_JOB: alpha_scan\n\nScan new launches, filter, score, log alpha. Tools: solana_scan_launches → filter (vol>30K, mcap>10K, liq>5K) → solana_token_snapshot for survivors → quality filter (top10 <50%, deployer <3 abandoned, has social) → score 0-100 → solana_alpha_log for 65+. Summarize results.",
915
- model: "anthropic/claude-sonnet-4-20250514",
917
+ model: HEAVY,
916
918
  thinking: false,
917
919
  lightContext: true,
918
920
  delivery: { mode: "announce", channel: "last", bestEffort: true },
@@ -924,7 +926,7 @@ function traderCronPrescriptiveJobs(agentId) {
924
926
  agentId,
925
927
  message:
926
928
  "CRON_JOB: portfolio_health\n\nCombined dead-money + whale + risk audit. solana_capital_status + solana_positions → solana_token_snapshot per position → dead money exit (loss>40% or 90min+down+low vol) → whale flags (>5% supply moves) → risk checks (concentration/drawdown/exposure) → sell if CRITICAL → solana_memory_write tag 'portfolio_health'.",
927
- model: "anthropic/claude-sonnet-4-20250514",
929
+ model: HEAVY,
928
930
  thinking: false,
929
931
  lightContext: true,
930
932
  delivery: { mode: "announce", channel: "last", bestEffort: true },
@@ -936,7 +938,7 @@ function traderCronPrescriptiveJobs(agentId) {
936
938
  agentId,
937
939
  message:
938
940
  "CRON_JOB: trust_refresh\n\nCombined source + deployer trust. solana_source_trust_refresh + solana_deployer_trust_refresh → solana_alpha_sources + solana_trades for win rates → solana_source_trust_get + solana_deployer_trust_get, flag <30 → solana_memory_write tag 'trust_refresh'.",
939
- model: "anthropic/claude-haiku-4-5",
941
+ model: LIGHT,
940
942
  thinking: false,
941
943
  lightContext: true,
942
944
  delivery: { mode: "none" },
@@ -948,7 +950,7 @@ function traderCronPrescriptiveJobs(agentId) {
948
950
  agentId,
949
951
  message:
950
952
  "CRON_JOB: meta_rotation_analysis\n\nx_search_tweets trending topics → solana_scan_launches → categorize by narrative cluster → per-cluster metrics → compare vs solana_memory_search tag 'meta_rotation' → declare hot/fading clusters → solana_memory_write tag 'meta_rotation'.",
951
- model: "anthropic/claude-sonnet-4-20250514",
953
+ model: HEAVY,
952
954
  thinking: false,
953
955
  lightContext: true,
954
956
  delivery: { mode: "announce", channel: "last", bestEffort: true },
@@ -960,7 +962,7 @@ function traderCronPrescriptiveJobs(agentId) {
960
962
  agentId,
961
963
  message:
962
964
  "CRON_JOB: strategy_evolution\n\nDaily strategy review. solana_journal_summary — if <10 closed trades since last evolution, log 'insufficient data' and stop. Otherwise: solana_trades to bucket by confidence tier → solana_strategy_state for current weights → analyze tier performance → solana_strategy_update with conservative adjustments (max 10% per weight per cycle) → solana_memory_write tag 'strategy_evolution'.",
963
- model: "anthropic/claude-sonnet-4-20250514",
965
+ model: HEAVY,
964
966
  thinking: true,
965
967
  lightContext: false,
966
968
  delivery: { mode: "announce", channel: "last", bestEffort: true },
@@ -972,7 +974,7 @@ function traderCronPrescriptiveJobs(agentId) {
972
974
  agentId,
973
975
  message:
974
976
  "CRON_JOB: subscription_cleanup\n\nsolana_positions for open CAs → solana_bitquery_subscriptions for active subs (if AUTH_SCOPE_MISSING, log and stop) → match subs to positions → solana_bitquery_unsubscribe orphaned subs → solana_memory_write tag 'subscription_cleanup'. Summarize before/after counts.",
975
- model: "anthropic/claude-haiku-4-5",
977
+ model: LIGHT,
976
978
  thinking: false,
977
979
  lightContext: true,
978
980
  delivery: { mode: "announce", channel: "last", bestEffort: true },
@@ -984,7 +986,7 @@ function traderCronPrescriptiveJobs(agentId) {
984
986
  agentId,
985
987
  message:
986
988
  "CRON_JOB: daily_performance_report\n\nCompile 24h report. solana_journal_summary + solana_capital_status + solana_positions + solana_trades + solana_strategy_state → sections: Portfolio Summary, Trading Activity (count/win rate/PnL), Best/Worst Trades, Strategy State, Risk Metrics, Recommendations → solana_memory_write tag 'daily_report'. Deliver full report.",
987
- model: "anthropic/claude-sonnet-4-20250514",
989
+ model: HEAVY,
988
990
  thinking: false,
989
991
  lightContext: false,
990
992
  delivery: { mode: "announce", channel: "telegram" },
@@ -996,7 +998,7 @@ function traderCronPrescriptiveJobs(agentId) {
996
998
  agentId,
997
999
  message:
998
1000
  "CRON_JOB: intelligence_lab_eval\n\nsolana_candidate_get — if <20 labeled candidates, log 'insufficient data' and exit. Otherwise: solana_evaluation_report → solana_model_registry for challengers → solana_replay_eval if challenger exists → solana_model_promote if challenger beats champion by >5% F1 → solana_memory_write tag 'intelligence_lab'.",
999
- model: "anthropic/claude-sonnet-4-20250514",
1001
+ model: HEAVY,
1000
1002
  thinking: true,
1001
1003
  lightContext: false,
1002
1004
  delivery: { mode: "none" },
@@ -1008,7 +1010,7 @@ function traderCronPrescriptiveJobs(agentId) {
1008
1010
  agentId,
1009
1011
  message:
1010
1012
  "CRON_JOB: memory_trim\n\nsolana_memory_trim dryRun:true first → review → solana_memory_trim retentionDays:2 → solana_memory_write tag 'memory_trim' with summary.",
1011
- model: "anthropic/claude-haiku-4-5",
1013
+ model: LIGHT,
1012
1014
  thinking: false,
1013
1015
  lightContext: true,
1014
1016
  delivery: { mode: "none" },
@@ -1020,7 +1022,7 @@ function traderCronPrescriptiveJobs(agentId) {
1020
1022
  agentId,
1021
1023
  message:
1022
1024
  "Balance watchdog. 1) solana_capital_status 2) solana_positions 3) solana_context_snapshot_read 4) Compare real vs believed. If mismatch: solana_context_snapshot_write with corrected state, summarize changes. If match: reply WATCHDOG_OK.",
1023
- model: "anthropic/claude-haiku-4-5",
1025
+ model: LIGHT,
1024
1026
  thinking: false,
1025
1027
  lightContext: true,
1026
1028
  delivery: { mode: "announce", channel: "telegram" },
@@ -1029,7 +1031,7 @@ function traderCronPrescriptiveJobs(agentId) {
1029
1031
  ];
1030
1032
  }
1031
1033
 
1032
- function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1034
+ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE, options = {}) {
1033
1035
  let config = {};
1034
1036
  try {
1035
1037
  config = JSON.parse(readFileSync(configPath, "utf-8"));
@@ -1047,9 +1049,12 @@ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1047
1049
  /** Default periodic wake interval for TraderClaw installs (was 5m; stretched to reduce load). */
1048
1050
  const defaultHeartbeatEvery = "30m";
1049
1051
 
1052
+ // target: "last" routes to the last channel that DM'd the agent (works as soon as the user
1053
+ // sends any message in Telegram). "telegram" requires per-channel default routing setup
1054
+ // and silently drops on first install — see VPS post-mortem.
1050
1055
  const defaultHeartbeat = {
1051
1056
  every: defaultHeartbeatEvery,
1052
- target: "telegram",
1057
+ target: "last",
1053
1058
  isolatedSession: true,
1054
1059
  lightContext: true,
1055
1060
  prompt: heartbeatPrompt,
@@ -1079,7 +1084,18 @@ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1079
1084
  if (existingIds.has(agent.id)) {
1080
1085
  const existing = config.agents.list.find(a => a.id === agent.id);
1081
1086
  if (agent.heartbeat) {
1082
- existing.heartbeat = agent.heartbeat;
1087
+ // Idempotent reinstall: never clobber an existing user-customized heartbeat block.
1088
+ // Only seed defaults that are missing — preserve any per-key overrides the operator
1089
+ // wrote between installs (cadence, tone, target=@username, etc.).
1090
+ if (!existing.heartbeat || typeof existing.heartbeat !== "object") {
1091
+ existing.heartbeat = { ...agent.heartbeat };
1092
+ } else {
1093
+ for (const [k, v] of Object.entries(agent.heartbeat)) {
1094
+ if (existing.heartbeat[k] === undefined || existing.heartbeat[k] === null || existing.heartbeat[k] === "") {
1095
+ existing.heartbeat[k] = v;
1096
+ }
1097
+ }
1098
+ }
1083
1099
  }
1084
1100
  if (agent.default) {
1085
1101
  existing.default = true;
@@ -1102,7 +1118,11 @@ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1102
1118
  const mainAgent = isV2 ? "cto" : "main";
1103
1119
 
1104
1120
  /** Six prescriptive managed jobs (VPS report); v2 assigns the same set to the CTO agent. */
1105
- const targetJobs = traderCronPrescriptiveJobs(mainAgent);
1121
+ const cronModels = resolveCronModels(
1122
+ String(options?.llmProvider || "anthropic").trim(),
1123
+ String(options?.llmModel || "").trim(),
1124
+ );
1125
+ const targetJobs = traderCronPrescriptiveJobs(mainAgent, cronModels);
1106
1126
 
1107
1127
  let removedLegacyCronJobs = false;
1108
1128
  if (config.cron && Object.prototype.hasOwnProperty.call(config.cron, "jobs")) {
@@ -1171,6 +1191,39 @@ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1171
1191
  }
1172
1192
  config.agents.defaults.heartbeat = { ...defaultHeartbeat };
1173
1193
 
1194
+ if (!config.agents.defaults.memorySearch || typeof config.agents.defaults.memorySearch !== "object") {
1195
+ config.agents.defaults.memorySearch = {
1196
+ provider: "openai",
1197
+ model: "text-embedding-3-small",
1198
+ query: {
1199
+ hybrid: true,
1200
+ mmr: { enabled: true, lambda: 0.5 },
1201
+ temporalDecay: { enabled: true, halfLifeDays: 14 },
1202
+ },
1203
+ };
1204
+ }
1205
+
1206
+ if (!config.agents.defaults.contextPruning || typeof config.agents.defaults.contextPruning !== "object") {
1207
+ config.agents.defaults.contextPruning = { cacheTtl: "1h" };
1208
+ }
1209
+
1210
+ if (!config.env || typeof config.env !== "object") config.env = {};
1211
+ if (process.env.OPENAI_API_KEY && !config.env.OPENAI_API_KEY) {
1212
+ // Literal env reference (not the value). The gateway expands ${OPENAI_API_KEY}
1213
+ // at runtime from its process environment. Keeps the secret out of the
1214
+ // config file on disk.
1215
+ config.env.OPENAI_API_KEY = "${OPENAI_API_KEY}";
1216
+ } else if (!process.env.OPENAI_API_KEY && !config.env.OPENAI_API_KEY && typeof console !== "undefined") {
1217
+ console.warn(
1218
+ "[traderclaw] OPENAI_API_KEY not detected in the installer shell. " +
1219
+ "Memory embeddings require it for vector search (text-embedding-3-small, ~$0.02/M tokens).\n" +
1220
+ "Set it and re-run the installer, or add the env ref manually:\n" +
1221
+ " 1) export OPENAI_API_KEY=sk-...\n" +
1222
+ " 2) edit ~/.openclaw/openclaw.json and set env.OPENAI_API_KEY to \"${OPENAI_API_KEY}\"\n" +
1223
+ " 3) openclaw gateway restart"
1224
+ );
1225
+ }
1226
+
1174
1227
  ensureAgentsDefaultsSchemaCompat(config);
1175
1228
  mkdirSync(CONFIG_DIR, { recursive: true });
1176
1229
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
@@ -1178,21 +1231,6 @@ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1178
1231
  const cronStorePath = resolveCronJobsStorePath(config);
1179
1232
  const cronMerge = mergeTraderCronJobsIntoStore(cronStorePath, targetJobs);
1180
1233
 
1181
- let qmdAvailable = false;
1182
- let qmdVersion = null;
1183
- try { qmdAvailable = commandExists("qmd"); } catch {}
1184
- if (qmdAvailable) {
1185
- qmdVersion = getCommandOutput("qmd --version");
1186
- } else {
1187
- if (typeof console !== "undefined") {
1188
- console.warn(
1189
- "[traderclaw] QMD binary not found. Memory engine will fall back to SQLite (no vector search, no temporal decay, no MMR).\n" +
1190
- "Install QMD: npm install -g @tobilu/qmd\n" +
1191
- "Then restart the gateway: openclaw gateway restart"
1192
- );
1193
- }
1194
- }
1195
-
1196
1234
  return {
1197
1235
  configPath,
1198
1236
  agentsConfigured: targetAgents.length,
@@ -1204,12 +1242,227 @@ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
1204
1242
  cronJobsStoreError: cronMerge.error,
1205
1243
  removedLegacyCronJobs,
1206
1244
  hooksConfigured: config.hooks.mappings.length,
1207
- qmdAvailable,
1208
- qmdVersion,
1209
1245
  isV2,
1210
1246
  };
1211
1247
  }
1212
1248
 
1249
+ /**
1250
+ * Persist the optional Telegram forward recipient onto the plugin entry config so the
1251
+ * orchestrator's reply-forward path works on first install. Idempotent — does nothing
1252
+ * when no recipient is provided. Accepts numeric chat ids (e.g. "818973873") or
1253
+ * @username (e.g. "@neabi").
1254
+ */
1255
+ function writeForwardTelegramRecipient(modeConfig, recipient, configPath = CONFIG_FILE) {
1256
+ if (typeof recipient !== "string" || !recipient.trim()) return { written: false };
1257
+ let config = {};
1258
+ try {
1259
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1260
+ } catch {
1261
+ config = {};
1262
+ }
1263
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
1264
+ if (!config.plugins.entries || typeof config.plugins.entries !== "object") config.plugins.entries = {};
1265
+ normalizeTraderPluginEntries(config, modeConfig.pluginId);
1266
+ const entry = config.plugins.entries[modeConfig.pluginId];
1267
+ if (!entry || typeof entry !== "object") return { written: false };
1268
+ if (!entry.config || typeof entry.config !== "object") entry.config = {};
1269
+ entry.config.forwardTelegramRecipient = recipient.trim();
1270
+ ensureAgentsDefaultsSchemaCompat(config);
1271
+ mkdirSync(CONFIG_DIR, { recursive: true });
1272
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1273
+ return { written: true, recipient: entry.config.forwardTelegramRecipient };
1274
+ }
1275
+
1276
+ /**
1277
+ * Approve any pending OpenClaw-paired devices on this host. Newly-paired devices
1278
+ * land with only `operator.read` scope; new beta plugin RPCs need `operator.write` +
1279
+ * `approvals` + `secrets`, so without approval the CLI loops on `1008 pairing required`.
1280
+ *
1281
+ * Best-effort: tolerates missing `openclaw devices` subcommand and an empty pending list.
1282
+ * Returns counts but never throws — callers wrap in try/catch.
1283
+ */
1284
+ async function approvePendingDevices() {
1285
+ if (!commandExists("openclaw")) return { ran: false, reason: "no_cli" };
1286
+ let listOut = "";
1287
+ try {
1288
+ listOut = execFileSync("openclaw", ["devices", "list", "--json"], {
1289
+ encoding: "utf-8",
1290
+ timeout: 15_000,
1291
+ env: NO_COLOR_ENV,
1292
+ }).trim();
1293
+ } catch (err) {
1294
+ return { ran: false, reason: "list_failed", error: err?.message || String(err) };
1295
+ }
1296
+ if (!listOut) return { ran: true, approved: 0, total: 0 };
1297
+
1298
+ let parsed;
1299
+ try {
1300
+ parsed = JSON.parse(listOut);
1301
+ } catch {
1302
+ return { ran: true, approved: 0, total: 0, reason: "list_not_json" };
1303
+ }
1304
+ const devices = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.devices) ? parsed.devices : [];
1305
+
1306
+ // Devices needing operator action: pending pair, awaiting approval, or flagged for repair
1307
+ // (repair is what existing devices get when new RPC scopes are introduced — see VPS post-mortem).
1308
+ const PENDING_STATES = new Set(["pending", "awaiting_approval", "needs_approval", "repair", "scope_repair"]);
1309
+ const allCandidates = devices.filter(
1310
+ (d) => d && typeof d === "object" && (PENDING_STATES.has(String(d.status || "").toLowerCase()) || d.requiresApproval === true),
1311
+ );
1312
+
1313
+ // Authorization-scope safety: only auto-approve devices that we can prove belong to
1314
+ // this host. Approving unrelated pending devices would silently grant the new
1315
+ // operator.write/approvals/secrets scopes to someone else's machine. Match strategies:
1316
+ // 1) device.hostname / device.host equals os.hostname()
1317
+ // 2) device.deviceId / device.fingerprint equals a fingerprint we just paired (env)
1318
+ // 3) operator opted in to broad approval via TRADERCLAW_INSTALLER_APPROVE_ALL_PENDING=1
1319
+ // If none match and there is more than one pending device, do nothing and surface a
1320
+ // log line so the operator can run `openclaw devices approve <id>` themselves.
1321
+ const myHost = String(os.hostname() || "").toLowerCase();
1322
+ const justPairedId = String(process.env.OPENCLAW_JUST_PAIRED_DEVICE_ID || "").trim();
1323
+ const optInBroad = process.env.TRADERCLAW_INSTALLER_APPROVE_ALL_PENDING === "1";
1324
+
1325
+ const matchesThisHost = (d) => {
1326
+ const dh = String(d.hostname || d.host || "").toLowerCase();
1327
+ const did = String(d.id || d.deviceId || d.fingerprint || "").trim();
1328
+ if (myHost && dh && dh === myHost) return true;
1329
+ if (justPairedId && did && did === justPairedId) return true;
1330
+ return false;
1331
+ };
1332
+
1333
+ let candidates = allCandidates.filter(matchesThisHost);
1334
+ // Conservative fallback: if exactly one pending candidate exists overall and no host
1335
+ // metadata is available to disambiguate, treat it as "the device we just paired".
1336
+ if (candidates.length === 0 && allCandidates.length === 1) {
1337
+ candidates = allCandidates;
1338
+ }
1339
+ if (optInBroad) {
1340
+ candidates = allCandidates;
1341
+ }
1342
+
1343
+ if (candidates.length === 0 && allCandidates.length > 1) {
1344
+ return {
1345
+ ran: true,
1346
+ approved: 0,
1347
+ total: devices.length,
1348
+ candidates: 0,
1349
+ pending: allCandidates.length,
1350
+ reason: "ambiguous_pending_devices",
1351
+ pendingIds: allCandidates.map((d) => String(d.id || d.deviceId || "")).filter(Boolean),
1352
+ };
1353
+ }
1354
+
1355
+ let approved = 0;
1356
+ const errors = [];
1357
+ for (const d of candidates) {
1358
+ const id = String(d.id || d.deviceId || "").trim();
1359
+ if (!id) continue;
1360
+ try {
1361
+ execFileSync("openclaw", ["devices", "approve", id], {
1362
+ encoding: "utf-8",
1363
+ timeout: 15_000,
1364
+ env: NO_COLOR_ENV,
1365
+ });
1366
+ approved += 1;
1367
+ } catch (err) {
1368
+ errors.push({ id, error: err?.message || String(err) });
1369
+ }
1370
+ }
1371
+ return { ran: true, approved, total: devices.length, candidates: candidates.length, errors };
1372
+ }
1373
+
1374
+ /**
1375
+ * Final invariant check before declaring install complete. Re-reads openclaw.json from
1376
+ * disk and verifies every default that the install promised to ship correctly. Throws
1377
+ * (failing the wizard) on any mismatch so a broken config never ships to a fresh user.
1378
+ */
1379
+ function runFreshInstallSmokeCheck(modeConfig, options, configPath = CONFIG_FILE) {
1380
+ const issues = [];
1381
+ let config;
1382
+ try {
1383
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1384
+ } catch (err) {
1385
+ return { ok: false, issues: [`Could not read ${configPath}: ${err?.message || String(err)}`] };
1386
+ }
1387
+
1388
+ // 1. Telegram dmPolicy must be "open" (only relevant when telegram was enabled)
1389
+ if (options?.enableTelegram === true) {
1390
+ const dmPolicy = config?.channels?.telegram?.dmPolicy;
1391
+ if (dmPolicy !== "open") {
1392
+ issues.push(`channels.telegram.dmPolicy is '${dmPolicy ?? "unset"}' (expected 'open' for wizard installs).`);
1393
+ }
1394
+ }
1395
+
1396
+ // 2. Every agent's heartbeat.target must be set to *something* routable. The fresh-install
1397
+ // default is "last", but a reinstall over a config where the operator intentionally chose
1398
+ // "@username" or "telegram:<id>" must NOT fail — configureGatewayScheduling preserves
1399
+ // those values, so failing here would block legitimate reinstalls. Only fail when the
1400
+ // agent has heartbeat config but no target at all (the regression we kept hitting).
1401
+ const agents = Array.isArray(config?.agents?.list) ? config.agents.list : [];
1402
+ for (const a of agents) {
1403
+ if (!a?.heartbeat) continue;
1404
+ const t = a.heartbeat.target;
1405
+ if (!t) {
1406
+ issues.push(`agents.list[id=${a.id}].heartbeat is configured but heartbeat.target is missing (expected 'last' or a routable target like '@username' / 'telegram:<id>').`);
1407
+ }
1408
+ }
1409
+
1410
+ // 3. Every X profile must have userId+username (only when X consumer keys are configured)
1411
+ const entry = config?.plugins?.entries?.[modeConfig.pluginId];
1412
+ const xCfg = entry?.config?.x;
1413
+ if (xCfg && xCfg.consumerKey && xCfg.consumerSecret && xCfg.profiles && typeof xCfg.profiles === "object") {
1414
+ for (const [pid, p] of Object.entries(xCfg.profiles)) {
1415
+ if (!p || typeof p !== "object") continue;
1416
+ if (!p.accessToken || !p.accessTokenSecret) continue;
1417
+ if (!p.userId || !p.username) {
1418
+ issues.push(`plugins.entries.${modeConfig.pluginId}.config.x.profiles.${pid} missing userId or username (x_read_mentions will fail).`);
1419
+ }
1420
+ }
1421
+ }
1422
+
1423
+ // 4. Every cron job's model must match the wizard provider
1424
+ const wantProvider = String(options?.llmProvider || "").trim();
1425
+ if (wantProvider) {
1426
+ let cronStorePath = "";
1427
+ try {
1428
+ cronStorePath = resolveCronJobsStorePath(config);
1429
+ } catch {
1430
+ cronStorePath = "";
1431
+ }
1432
+ if (cronStorePath && existsSync(cronStorePath)) {
1433
+ let store = null;
1434
+ try {
1435
+ store = JSON.parse(readFileSync(cronStorePath, "utf-8"));
1436
+ } catch (err) {
1437
+ issues.push(`cron store '${cronStorePath}' could not be parsed: ${err?.message || String(err)} — provider/model alignment cannot be verified.`);
1438
+ }
1439
+ if (store) {
1440
+ // Only enforce provider alignment on installer-managed TraderClaw jobs. Custom
1441
+ // jobs the operator added between installs (or other plugins' jobs) are intentionally
1442
+ // preserved by mergeTraderCronJobsIntoStore and must not fail the smoke check.
1443
+ const managedIds = new Set([
1444
+ ...traderCronPrescriptiveJobs("main").map((j) => j.id),
1445
+ ...traderCronPrescriptiveJobs("cto").map((j) => j.id),
1446
+ ].filter(Boolean));
1447
+ const jobs = Array.isArray(store?.jobs) ? store.jobs : [];
1448
+ for (const j of jobs) {
1449
+ if (!j || typeof j !== "object") continue;
1450
+ if (!managedIds.has(j.id)) continue;
1451
+ // OpenClaw cron store nests model under job.payload.model (see buildOpenClawCronStoreJob).
1452
+ // Fall back to top-level j.model only as a defensive read in case the schema changes.
1453
+ const m = String(j?.payload?.model || j?.model || "");
1454
+ if (!m) continue;
1455
+ if (!m.startsWith(`${wantProvider}/`)) {
1456
+ issues.push(`installer-managed cron job '${j.id}' has model '${m}' but wizard provider is '${wantProvider}'.`);
1457
+ }
1458
+ }
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ return { ok: issues.length === 0, issues, configPath };
1464
+ }
1465
+
1213
1466
  function ensureOpenResponsesEnabled(configPath = CONFIG_FILE) {
1214
1467
  let config = {};
1215
1468
  try {
@@ -1373,19 +1626,40 @@ function seedXConfig(modeConfig, configPath = CONFIG_FILE, wizardOpts = {}) {
1373
1626
  entry.config.x.profiles = {};
1374
1627
  }
1375
1628
 
1376
- const agentIds =
1629
+ // V1 historically only seeded { main, solana-trader }. Production traffic resolves the X
1630
+ // profile via callerAgentId || requestedAgentId || fallbackAgentId || "cto" — so a missing
1631
+ // "cto" profile (the literal string-fallback) breaks x_read_mentions for any caller that
1632
+ // doesn't explicitly pass agentId. Always seed "cto" for V1 too, mirroring "main" tokens
1633
+ // when no dedicated cto credentials were supplied.
1634
+ // Additionally, mirror under any agents.list[].identity.name (e.g. "AgentZERO") so any
1635
+ // identity-name-based resolution path lands on a real profile.
1636
+ const baseAgentIds =
1377
1637
  modeConfig.pluginId === "solana-trader-v2"
1378
1638
  ? ["cto", "intern"]
1379
1639
  : modeConfig.pluginId === "solana-trader"
1380
- ? ["main", "solana-trader"]
1640
+ ? ["main", "solana-trader", "cto"]
1381
1641
  : ["main"];
1642
+
1643
+ const identityNames = new Set();
1644
+ if (Array.isArray(config?.agents?.list)) {
1645
+ for (const a of config.agents.list) {
1646
+ const n = typeof a?.identity?.name === "string" ? a.identity.name.trim() : "";
1647
+ if (n && !baseAgentIds.includes(n)) identityNames.add(n);
1648
+ }
1649
+ }
1650
+ const agentIds = [...baseAgentIds, ...identityNames];
1651
+
1382
1652
  let profilesFound = 0;
1383
1653
 
1384
1654
  for (const agentId of agentIds) {
1385
1655
  let { at, ats } = getAccessPairForAgent(wizardOpts, agentId);
1656
+ // Mirror "main" tokens for any V1 secondary slot (solana-trader, cto, AgentZERO, …)
1657
+ // when no dedicated tokens were supplied. Production X tools are stateless w.r.t.
1658
+ // which token issued the call, so reusing "main" tokens across profiles is safe and
1659
+ // matches what the user manually does on every install.
1386
1660
  if (
1387
1661
  modeConfig.pluginId === "solana-trader"
1388
- && agentId === "solana-trader"
1662
+ && agentId !== "main"
1389
1663
  && (!at || !ats)
1390
1664
  ) {
1391
1665
  ({ at, ats } = getAccessPairForAgent(wizardOpts, "main"));
@@ -1897,9 +2171,9 @@ function verifyInstallation(modeConfig, apiKey) {
1897
2171
  note: heartbeatInWorkspace ? workspaceRoot : `expected ${join(workspaceRoot, "HEARTBEAT.md")}`,
1898
2172
  },
1899
2173
  {
1900
- label: "QMD memory engine (vector search)",
1901
- ok: commandExists("qmd"),
1902
- note: "not installed memory uses keyword search only. Install: npm install -g @tobilu/qmd",
2174
+ label: "Memory engine (builtin, vector + FTS)",
2175
+ ok: true,
2176
+ note: "run `openclaw memory status --deep` to verify index",
1903
2177
  },
1904
2178
  ];
1905
2179
  }
@@ -1935,6 +2209,13 @@ export class InstallerStepEngine {
1935
2209
  gatewayToken: options.gatewayToken || "",
1936
2210
  enableTelegram: options.enableTelegram === true,
1937
2211
  telegramToken: options.telegramToken || "",
2212
+ // Optional: numeric chat id or @username the orchestrator forwards Telegram replies to.
2213
+ // When set, written to plugins.entries[<pluginId>].config.forwardTelegramRecipient so the
2214
+ // forward path works on first install without a manual edit. Legacy alias accepted.
2215
+ forwardTelegramRecipient:
2216
+ typeof options.forwardTelegramRecipient === "string" ? options.forwardTelegramRecipient.trim()
2217
+ : typeof options.forwardTelegramChatId === "string" ? options.forwardTelegramChatId.trim()
2218
+ : "",
1938
2219
  autoInstallDeps: options.autoInstallDeps !== false,
1939
2220
  skipPreflight: options.skipPreflight === true,
1940
2221
  skipInstallOpenClaw: options.skipInstallOpenClaw === true,
@@ -2182,6 +2463,17 @@ export class InstallerStepEngine {
2182
2463
  writeTelegramChannelConfig(this.options.telegramToken, CONFIG_FILE);
2183
2464
  this.emitLog("telegram_required", "info", "Telegram bot token written to openclaw.json (channels.telegram.botToken).");
2184
2465
 
2466
+ if (this.options.forwardTelegramRecipient) {
2467
+ const fwd = writeForwardTelegramRecipient(this.modeConfig, this.options.forwardTelegramRecipient, CONFIG_FILE);
2468
+ if (fwd.written) {
2469
+ this.emitLog(
2470
+ "telegram_required",
2471
+ "info",
2472
+ `Forward recipient written to plugins.entries.${this.modeConfig.pluginId}.config.forwardTelegramRecipient = ${fwd.recipient}.`,
2473
+ );
2474
+ }
2475
+ }
2476
+
2185
2477
  const policy = ensureTelegramGroupPolicyOpenForWizard();
2186
2478
  if (policy.changed) {
2187
2479
  this.emitLog(
@@ -2383,40 +2675,6 @@ export class InstallerStepEngine {
2383
2675
  if (!this.options.skipInstallOpenClaw) {
2384
2676
  await this.runStep("install_openclaw", "Installing or upgrading OpenClaw platform", async () => installOpenClawPlatform());
2385
2677
  }
2386
-
2387
- // Non-fatal: warn when the CLI has devices in pending-approval or repair state.
2388
- // Gateway >= 1.0.93-beta.0 requires explicit device approval for operator-write scope;
2389
- // without it, agent trading RPCs silently fail (device gets read-only "repair" state).
2390
- await this.runStep("device_approval_check", "Checking OpenClaw device approval status", async () => {
2391
- const check = checkOpenClawDeviceApproval();
2392
- if (!check.ran) {
2393
- this.emitLog("device_approval_check", "info", "Device approval check skipped (openclaw CLI not available or devices subcommand not supported).");
2394
- return { ran: false };
2395
- }
2396
- const needsAction = check.pendingIds.length > 0 || check.repairDetected;
2397
- if (!needsAction) {
2398
- this.emitLog("device_approval_check", "info", "No pending or repair-state devices found. Device approval OK.");
2399
- return { ran: true, ok: true };
2400
- }
2401
- const lines = [
2402
- "ACTION REQUIRED — OpenClaw device approval needed.",
2403
- "The gateway requires explicit device approval for operator-write scope.",
2404
- "Without it, trading RPCs will fail silently (read-only / repair state).",
2405
- "",
2406
- "Run in your VPS shell:",
2407
- " openclaw devices list",
2408
- ...(check.pendingIds.length > 0
2409
- ? check.pendingIds.map((id) => ` openclaw devices approve ${id}`)
2410
- : [" openclaw devices approve <requestId> # use the id shown above"]),
2411
- "",
2412
- check.envTokenSet
2413
- ? "OPENCLAW_GATEWAY_TOKEN env var is already set — env-first auth will work as a fallback."
2414
- : "Optionally set: export OPENCLAW_GATEWAY_TOKEN=\"<token>\" # bypasses device auth entirely",
2415
- ];
2416
- this.emitLog("device_approval_check", "warn", lines.join("\n"));
2417
- return { ran: true, ok: false, pendingIds: check.pendingIds, repairDetected: check.repairDetected, envTokenSet: check.envTokenSet };
2418
- });
2419
-
2420
2678
  await this.runStep("configure_llm", "Configuring required OpenClaw LLM provider", async () => this.configureLlmStep());
2421
2679
  if (!this.options.skipInstallPlugin) {
2422
2680
  await this.runStep("install_plugin_package", "Installing TraderClaw CLI package", async () =>
@@ -2427,32 +2685,6 @@ export class InstallerStepEngine {
2427
2685
  await this.runStep("openclaw_global_deps", "Ensuring OpenClaw global package dependencies", async () =>
2428
2686
  ensureOpenClawGlobalPackageDependencies(),
2429
2687
  );
2430
- await this.runStep("install_qmd", "Installing QMD memory engine (vector search)", async () => {
2431
- if (commandExists("qmd")) {
2432
- const ver = getCommandOutput("qmd --version");
2433
- this.emitLog("install_qmd", "info", `QMD already installed: ${ver}`);
2434
- return { alreadyInstalled: true, version: ver };
2435
- }
2436
- this.emitLog("install_qmd", "info", "Installing @tobilu/qmd globally for vector search memory...");
2437
- try {
2438
- await runCommandWithEvents("npm", ["install", "-g", "--ignore-scripts", "--registry", "https://registry.npmjs.org/", "@tobilu/qmd"], {
2439
- onEvent: (evt) => this.emitLog("install_qmd", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
2440
- });
2441
- } catch (err) {
2442
- this.emitLog(
2443
- "install_qmd",
2444
- "warn",
2445
- `QMD install failed (non-fatal): ${err?.message || err}. Memory will use keyword search only. You can install manually later: npm install -g @tobilu/qmd`,
2446
- );
2447
- return { installed: false, error: err?.message || String(err) };
2448
- }
2449
- const available = commandExists("qmd");
2450
- const ver = available ? getCommandOutput("qmd --version") : null;
2451
- if (!available) {
2452
- this.emitLog("install_qmd", "warn", "QMD installed but not on PATH. Memory will use keyword search only.");
2453
- }
2454
- return { installed: available, version: ver };
2455
- });
2456
2688
  await this.runStep(
2457
2689
  "activate_openclaw_plugin",
2458
2690
  "Installing and enabling TraderClaw inside OpenClaw",
@@ -2530,6 +2762,33 @@ export class InstallerStepEngine {
2530
2762
  runPrivileged: (cmd, args) => this.runWithPrivilegeGuidance("gateway_persistence", cmd, args),
2531
2763
  });
2532
2764
  });
2765
+
2766
+ // Best-effort: approve the device that was just paired by the wizard so the new
2767
+ // beta-RPC scopes (operator.write/approvals/secrets) are granted before the user
2768
+ // hits a tool. Tolerates missing CLI subcommands; never fails the install.
2769
+ await this.runStep("device_approval", "Approving freshly paired device", async () => {
2770
+ try {
2771
+ const result = await approvePendingDevices();
2772
+ if (!result.ran) {
2773
+ this.emitLog("device_approval", "info", `Skipped (${result.reason || "unknown"}). Approve manually with 'openclaw devices approve <id>' if RPC calls return 1008.`);
2774
+ return { skipped: true, reason: result.reason };
2775
+ }
2776
+ if (result.approved > 0) {
2777
+ this.emitLog("device_approval", "info", `Approved ${result.approved}/${result.candidates ?? result.approved} pending device(s) (of ${result.total} total).`);
2778
+ } else {
2779
+ this.emitLog("device_approval", "info", `No devices required approval (${result.total} total).`);
2780
+ }
2781
+ if (Array.isArray(result.errors) && result.errors.length > 0) {
2782
+ for (const e of result.errors) {
2783
+ this.emitLog("device_approval", "warn", `Could not approve device ${e.id}: ${e.error}`);
2784
+ }
2785
+ }
2786
+ return result;
2787
+ } catch (err) {
2788
+ this.emitLog("device_approval", "warn", `Device approval skipped: ${err?.message || String(err)}. Approve manually with 'openclaw devices approve <id>' if RPC calls return 1008.`);
2789
+ return { skipped: true, error: err?.message || String(err) };
2790
+ }
2791
+ });
2533
2792
  }
2534
2793
 
2535
2794
  await this.runStep("enable_responses", "Enabling /v1/responses endpoint", async () => {
@@ -2539,7 +2798,7 @@ export class InstallerStepEngine {
2539
2798
  });
2540
2799
 
2541
2800
  await this.runStep("gateway_scheduling", "Configuring heartbeat and cron schedules", async () => {
2542
- const result = configureGatewayScheduling(this.modeConfig, CONFIG_FILE);
2801
+ const result = configureGatewayScheduling(this.modeConfig, CONFIG_FILE, this.options);
2543
2802
  this.emitLog("gateway_scheduling", "info", `Agents configured: ${result.agentsConfigured}`);
2544
2803
  if (result.cronJobsStoreWriteOk) {
2545
2804
  this.emitLog(
@@ -2560,17 +2819,6 @@ export class InstallerStepEngine {
2560
2819
  this.emitLog("gateway_scheduling", "warn", "Removed legacy 'cron.jobs' from openclaw.json to keep config validation compatible.");
2561
2820
  }
2562
2821
  this.emitLog("gateway_scheduling", "info", `Webhook hooks: ${result.hooksConfigured}`);
2563
- if (!result.qmdAvailable) {
2564
- this.emitLog(
2565
- "gateway_scheduling",
2566
- "warn",
2567
- "QMD binary not found — memory will use SQLite keyword search only (no vector search, no temporal decay, no MMR). " +
2568
- "Vector search makes the agent's memory significantly more effective. " +
2569
- "Install: npm install -g @tobilu/qmd — then restart the gateway: openclaw gateway restart",
2570
- );
2571
- } else {
2572
- this.emitLog("gateway_scheduling", "info", `QMD memory engine: ${result.qmdVersion || "installed"}`);
2573
- }
2574
2822
  const restart = await restartGateway();
2575
2823
  return { ...result, restart };
2576
2824
  });
@@ -2628,23 +2876,71 @@ export class InstallerStepEngine {
2628
2876
  this.emitLog("x_credentials", "warn", `Missing X profiles for: ${missing.join(", ")}. Set tokens in the wizard or X_ACCESS_TOKEN_<AGENT_ID> / X_ACCESS_TOKEN_<AGENT_ID>_SECRET env vars.`);
2629
2877
  }
2630
2878
  const { consumerKey, consumerSecret } = getConsumerKeysFromWizard(this.options);
2879
+ // Group agentIds by unique (accessToken, accessTokenSecret) pair so we only call
2880
+ // GET /2/users/me once per real X account, then apply the resolved userId+username
2881
+ // to every profile that uses that token pair. Previously the loop verified per-agent
2882
+ // and silently skipped persisting userId on profiles whose verify call had transient
2883
+ // failures — leaving x_read_mentions broken on those agents.
2884
+ const pairGroups = new Map();
2885
+ const pairOwner = new Map();
2886
+ const isV1 = this.modeConfig.pluginId === "solana-trader";
2887
+ for (const agentId of result.agentIds) {
2888
+ let { at, ats } = getAccessPairForAgent(this.options, agentId);
2889
+ // Mirror seedXConfig fallback: V1 secondary slots reuse main's tokens when no
2890
+ // dedicated pair was supplied. Without this, mirrored profiles (cto / solana-trader /
2891
+ // identity-name agents) get seeded with main tokens but skipped here, leaving their
2892
+ // userId/username unpopulated and breaking x_read_mentions on those agents.
2893
+ if (isV1 && agentId !== "main" && (!at || !ats)) {
2894
+ ({ at, ats } = getAccessPairForAgent(this.options, "main"));
2895
+ }
2896
+ if (!at || !ats) continue;
2897
+ const key = `${at}::${ats}`;
2898
+ if (!pairGroups.has(key)) {
2899
+ pairGroups.set(key, { at, ats, agentIds: [] });
2900
+ pairOwner.set(key, agentId);
2901
+ }
2902
+ pairGroups.get(key).agentIds.push(agentId);
2903
+ }
2904
+
2631
2905
  const verified = [];
2632
2906
  const identitiesToPersist = [];
2633
- for (const agentId of result.agentIds) {
2634
- const { at, ats } = getAccessPairForAgent(this.options, agentId);
2635
- if (at && ats) {
2907
+ for (const [key, group] of pairGroups) {
2908
+ const owner = pairOwner.get(key);
2909
+ let check = null;
2910
+ let lastErr = null;
2911
+ for (let attempt = 1; attempt <= 2; attempt++) {
2636
2912
  try {
2637
- const check = await verifyXCredentials(consumerKey, consumerSecret, at, ats);
2638
- if (check.ok) {
2639
- this.emitLog("x_credentials", "info", `Verified X profile '${agentId}': @${check.username} (${check.userId})`);
2640
- verified.push({ agentId, username: check.username, userId: check.userId });
2641
- identitiesToPersist.push({ agentId, userId: check.userId, username: check.username });
2642
- } else {
2643
- this.emitLog("x_credentials", "warn", `X credential verification failed for '${agentId}': HTTP ${check.status}`);
2913
+ check = await verifyXCredentials(consumerKey, consumerSecret, group.at, group.ats);
2914
+ if (check.ok) break;
2915
+ lastErr = `HTTP ${check.status}`;
2916
+ if (attempt < 2) {
2917
+ this.emitLog("x_credentials", "warn", `Verify failed for token-pair owner '${owner}' (HTTP ${check.status}); retrying once.`);
2918
+ await new Promise(r => setTimeout(r, 1500));
2644
2919
  }
2645
2920
  } catch (err) {
2646
- this.emitLog("x_credentials", "warn", `X credential verification error for '${agentId}': ${err?.message || String(err)}`);
2921
+ lastErr = err?.message || String(err);
2922
+ if (attempt < 2) {
2923
+ this.emitLog("x_credentials", "warn", `Verify error for token-pair owner '${owner}' (${lastErr}); retrying once.`);
2924
+ await new Promise(r => setTimeout(r, 1500));
2925
+ }
2926
+ }
2927
+ }
2928
+ if (check && check.ok && check.userId && check.username) {
2929
+ this.emitLog(
2930
+ "x_credentials",
2931
+ "info",
2932
+ `Verified X token-pair (owner '${owner}'): @${check.username} (${check.userId}) → applying to ${group.agentIds.length} profile(s): ${group.agentIds.join(", ")}`,
2933
+ );
2934
+ for (const agentId of group.agentIds) {
2935
+ verified.push({ agentId, username: check.username, userId: check.userId });
2936
+ identitiesToPersist.push({ agentId, userId: check.userId, username: check.username });
2647
2937
  }
2938
+ } else {
2939
+ this.emitLog(
2940
+ "x_credentials",
2941
+ "warn",
2942
+ `X credential verification failed after retry for token-pair owner '${owner}' (${lastErr}). Profiles ${group.agentIds.join(", ")} will not have userId/username persisted; x_read_mentions will fail until tokens are corrected.`,
2943
+ );
2648
2944
  }
2649
2945
  }
2650
2946
  if (identitiesToPersist.length > 0) {
@@ -2663,6 +2959,25 @@ export class InstallerStepEngine {
2663
2959
  return { checks };
2664
2960
  });
2665
2961
 
2962
+ // Final invariant check: re-read openclaw.json and confirm every default the
2963
+ // wizard promised actually landed on disk. Catches regressions where a later
2964
+ // step silently overwrites an earlier one (the class of bug behind every manual
2965
+ // VPS fix in the post-mortem). Fails the install rather than shipping a broken
2966
+ // config to the user.
2967
+ await this.runStep("final_smoke_check", "Verifying fresh-install invariants", async () => {
2968
+ const result = runFreshInstallSmokeCheck(this.modeConfig, this.options, CONFIG_FILE);
2969
+ if (!result.ok) {
2970
+ for (const issue of result.issues) {
2971
+ this.emitLog("final_smoke_check", "warn", issue);
2972
+ }
2973
+ throw new Error(
2974
+ `Fresh-install smoke check failed (${result.issues.length} issue${result.issues.length === 1 ? "" : "s"}). The bot will not work out of the box. See warnings above.`,
2975
+ );
2976
+ }
2977
+ this.emitLog("final_smoke_check", "info", "All fresh-install invariants OK (dmPolicy=open, heartbeat.target=last, X profiles have userId/username, cron models match provider).");
2978
+ return result;
2979
+ });
2980
+
2666
2981
  this.state.status = "completed";
2667
2982
  this.state.completedAt = nowIso();
2668
2983
  return this.state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "traderclaw-cli",
3
- "version": "1.0.93",
3
+ "version": "1.0.95-beta.0",
4
4
  "description": "Global TraderClaw CLI (install --wizard, setup, precheck). Installs solana-traderclaw as a dependency for OpenClaw plugin files.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "node": ">=22"
18
18
  },
19
19
  "dependencies": {
20
- "solana-traderclaw": "^1.0.93"
20
+ "solana-traderclaw": "^1.0.95-beta.0"
21
21
  },
22
22
  "keywords": [
23
23
  "traderclaw",