solana-traderclaw 1.0.19

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.
@@ -0,0 +1,1422 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { randomBytes } from "crypto";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { dirname, join } from "path";
6
+ import { choosePreferredProviderModel } from "./llm-model-preference.mjs";
7
+ import { getLinuxGatewayPersistenceSnapshot } from "./gateway-persistence-linux.mjs";
8
+
9
+ const CONFIG_DIR = join(homedir(), ".openclaw");
10
+ const CONFIG_FILE = join(CONFIG_DIR, "openclaw.json");
11
+
12
+ /** Older `plugins.entries` keys / npm-era ids to merge orchestrator URL for. */
13
+ const LEGACY_TRADER_PLUGIN_IDS = ["traderclaw-v1", "solana-traderclaw-v1", "solana-trader"];
14
+
15
+ function stripAnsi(text) {
16
+ if (typeof text !== "string") return text;
17
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
18
+ }
19
+
20
+ /**
21
+ * OpenClaw defaults Telegram to groupPolicy "allowlist" with empty groupAllowFrom, so Doctor warns on
22
+ * every gateway restart and group messages are dropped. Wizard onboarding targets DMs first; set
23
+ * explicit "open" unless the user already configured sender allowlists.
24
+ */
25
+ function ensureTelegramGroupPolicyOpenForWizard(configPath = CONFIG_FILE) {
26
+ if (!existsSync(configPath)) return { changed: false };
27
+ let config = {};
28
+ try {
29
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
30
+ } catch {
31
+ return { changed: false };
32
+ }
33
+ if (!config.channels || typeof config.channels !== "object") return { changed: false };
34
+ const tg = config.channels.telegram;
35
+ if (!tg || typeof tg !== "object") return { changed: false };
36
+
37
+ const hasSenderAllowlist =
38
+ (Array.isArray(tg.groupAllowFrom) && tg.groupAllowFrom.length > 0) ||
39
+ (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0);
40
+ if (hasSenderAllowlist) return { changed: false };
41
+ if (tg.groupPolicy === "open") return { changed: false };
42
+
43
+ tg.groupPolicy = "open";
44
+ mkdirSync(CONFIG_DIR, { recursive: true });
45
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
46
+ return { changed: true };
47
+ }
48
+
49
+ function commandExists(cmd) {
50
+ try {
51
+ execSync(`command -v ${cmd}`, { stdio: "ignore", shell: true });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function getCommandOutput(cmd) {
59
+ try {
60
+ return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], shell: true }).trim();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function extractUrls(text = "") {
67
+ const matches = text.match(/https?:\/\/[^\s"')]+/g);
68
+ return matches ? [...new Set(matches)] : [];
69
+ }
70
+
71
+ function shellQuote(value) {
72
+ const raw = String(value ?? "");
73
+ if (raw.length === 0) return "''";
74
+ return `'${raw.replace(/'/g, `'\\''`)}'`;
75
+ }
76
+
77
+ function buildCommandString(cmd, args = []) {
78
+ return [cmd, ...args].map((part) => shellQuote(part)).join(" ");
79
+ }
80
+
81
+ function isPrivilegeError(err) {
82
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
83
+ return (
84
+ text.includes("permission denied")
85
+ || text.includes("eacces")
86
+ || text.includes("access denied")
87
+ || text.includes("operation not permitted")
88
+ || text.includes("must be root")
89
+ || text.includes("requires root")
90
+ || text.includes("sudo")
91
+ || text.includes("authentication is required")
92
+ );
93
+ }
94
+
95
+ function isRootUser() {
96
+ return typeof process.getuid === "function" && process.getuid() === 0;
97
+ }
98
+
99
+ function canUseSudoWithoutPrompt() {
100
+ try {
101
+ execSync("sudo -n true", { stdio: "ignore", shell: true });
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function tailscalePermissionRemediation() {
109
+ return [
110
+ "Tailscale requires elevated permissions on this host.",
111
+ "Run these commands in your terminal, then click Start Installation again:",
112
+ "1) sudo tailscale set --operator=$USER",
113
+ "2) sudo tailscale up",
114
+ "3) tailscale status",
115
+ ].join("\n");
116
+ }
117
+
118
+ function privilegeRemediationMessage(cmd, args = [], customLines = []) {
119
+ const command = buildCommandString(cmd, args);
120
+ const lines = [
121
+ "This step needs elevated privileges on this host.",
122
+ "Run this command in your terminal, then click Start Installation again:",
123
+ `sudo ${command}`,
124
+ ];
125
+ if (customLines.length > 0) {
126
+ lines.push(...customLines);
127
+ }
128
+ return lines.join("\n");
129
+ }
130
+
131
+ function gatewayTimeoutRemediation() {
132
+ return [
133
+ "Gateway bootstrap timed out waiting for health checks.",
134
+ "Run these commands in terminal, then click Start Installation again:",
135
+ "1) openclaw gateway status --json || true",
136
+ "2) openclaw gateway probe || true",
137
+ "3) openclaw gateway stop || true",
138
+ "4) openclaw gateway install",
139
+ "5) openclaw gateway restart",
140
+ "6) openclaw gateway status --json",
141
+ "7) tailscale funnel --bg 18789",
142
+ "8) tailscale funnel status",
143
+ "If gateway still fails on a low-memory VM, add swap or use a larger staging size (>=2GB RAM recommended).",
144
+ ].join("\n");
145
+ }
146
+
147
+ function gatewayModeUnsetRemediation() {
148
+ return [
149
+ "Gateway start is blocked because gateway.mode is unset.",
150
+ "Run these commands in terminal, then click Start Installation again:",
151
+ "1) cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak.$(date +%s) || true",
152
+ "2) openclaw config set gateway.mode local",
153
+ "3) openclaw config set gateway.bind loopback",
154
+ "4) openclaw gateway restart",
155
+ "5) openclaw gateway status --json",
156
+ ].join("\n");
157
+ }
158
+
159
+ function runCommandWithEvents(cmd, args = [], opts = {}) {
160
+ return new Promise((resolve, reject) => {
161
+ const child = spawn(cmd, args, {
162
+ stdio: "pipe",
163
+ shell: true,
164
+ ...opts,
165
+ });
166
+
167
+ let stdout = "";
168
+ let stderr = "";
169
+ const onEvent = typeof opts.onEvent === "function" ? opts.onEvent : null;
170
+ const emit = (event) => onEvent && onEvent(event);
171
+
172
+ child.stdout?.on("data", (d) => {
173
+ const text = d.toString();
174
+ stdout += text;
175
+ emit({ type: "stdout", text, urls: extractUrls(text) });
176
+ });
177
+
178
+ child.stderr?.on("data", (d) => {
179
+ const text = d.toString();
180
+ stderr += text;
181
+ emit({ type: "stderr", text, urls: extractUrls(text) });
182
+ });
183
+
184
+ child.on("close", (code) => {
185
+ const urls = [...new Set([...extractUrls(stdout), ...extractUrls(stderr)])];
186
+ if (code === 0) resolve({ stdout, stderr, code, urls });
187
+ else {
188
+ const raw = (stderr || "").trim();
189
+ const tailLines = raw.split("\n").filter((l) => l.length > 0).slice(-40).join("\n");
190
+ const stderrPreview = tailLines.length > 8000 ? tailLines.slice(-8000) : tailLines;
191
+ const err = new Error(stderrPreview ? `command failed with exit code ${code}: ${stderrPreview}` : `command failed with exit code ${code}`);
192
+ err.code = code;
193
+ err.stdout = stdout;
194
+ err.stderr = stderr;
195
+ err.urls = urls;
196
+ reject(err);
197
+ }
198
+ });
199
+ child.on("error", reject);
200
+ });
201
+ }
202
+
203
+ async function installOpenClawPlatform() {
204
+ if (commandExists("openclaw")) {
205
+ return { alreadyInstalled: true, version: getCommandOutput("openclaw --version") };
206
+ }
207
+ await runCommandWithEvents("npm", ["install", "-g", "openclaw"]);
208
+ return { alreadyInstalled: false, installed: true, available: commandExists("openclaw") };
209
+ }
210
+
211
+ function isNpmGlobalBinConflict(err, cliName) {
212
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
213
+ return (
214
+ text.includes("eexist")
215
+ && text.includes("/usr/bin/")
216
+ && text.includes(String(cliName || "").toLowerCase())
217
+ );
218
+ }
219
+
220
+ async function installPlugin(modeConfig, onEvent) {
221
+ try {
222
+ await runCommandWithEvents("npm", ["install", "-g", modeConfig.pluginPackage], { onEvent });
223
+ return { installed: true, available: commandExists(modeConfig.cliName), forced: false };
224
+ } catch (err) {
225
+ if (!isNpmGlobalBinConflict(err, modeConfig.cliName)) throw err;
226
+ if (typeof onEvent === "function") {
227
+ onEvent({
228
+ type: "stderr",
229
+ text: `Detected existing global binary conflict for '${modeConfig.cliName}'. Retrying npm install with --force.\n`,
230
+ urls: [],
231
+ });
232
+ }
233
+ await runCommandWithEvents("npm", ["install", "-g", "--force", modeConfig.pluginPackage], { onEvent });
234
+ return { installed: true, available: commandExists(modeConfig.cliName), forced: true };
235
+ }
236
+ }
237
+
238
+ function isPluginAlreadyExistsError(err, pluginId) {
239
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
240
+ return text.includes("plugin already exists")
241
+ || text.includes(`/extensions/${String(pluginId || "").toLowerCase()}`);
242
+ }
243
+
244
+ function backupExistingPluginDir(pluginId, onEvent) {
245
+ const pluginDir = join(CONFIG_DIR, "extensions", pluginId);
246
+ if (!existsSync(pluginDir)) return null;
247
+
248
+ const backupPath = `${pluginDir}.bak.${Date.now()}`;
249
+ renameSync(pluginDir, backupPath);
250
+ if (typeof onEvent === "function") {
251
+ onEvent({
252
+ type: "stdout",
253
+ text: `Detected existing plugin directory. Backed up '${pluginDir}' to '${backupPath}' before reinstall.\n`,
254
+ urls: [],
255
+ });
256
+ }
257
+ return { pluginDir, backupPath };
258
+ }
259
+
260
+ async function installAndEnableOpenClawPlugin(modeConfig, onEvent, orchestratorUrl) {
261
+ // `openclaw plugins install` calls writeConfigFile *during* the command. Plugin config schema
262
+ // requires orchestratorUrl — so we must seed it *before* install, not only after.
263
+ // Also merge legacy plugins.entries.* (see LEGACY_TRADER_PLUGIN_IDS) so old configs still validate.
264
+ mkdirSync(CONFIG_DIR, { recursive: true });
265
+ mkdirSync(join(CONFIG_DIR, "extensions"), { recursive: true });
266
+
267
+ seedPluginConfig(modeConfig, orchestratorUrl || "https://api.traderclaw.ai");
268
+
269
+ let recoveredExistingDir = null;
270
+ try {
271
+ await runCommandWithEvents("openclaw", ["plugins", "install", modeConfig.pluginPackage], { onEvent });
272
+ } catch (err) {
273
+ if (!isPluginAlreadyExistsError(err, modeConfig.pluginId)) {
274
+ throw err;
275
+ }
276
+ recoveredExistingDir = backupExistingPluginDir(modeConfig.pluginId, onEvent);
277
+ if (!recoveredExistingDir) {
278
+ throw err;
279
+ }
280
+ await runCommandWithEvents("openclaw", ["plugins", "install", modeConfig.pluginPackage], { onEvent });
281
+ }
282
+
283
+ // Manifest is on disk now; merge orchestrator URL before enable (plugin config schema may require it).
284
+ seedPluginConfig(modeConfig, orchestratorUrl || "https://api.traderclaw.ai");
285
+
286
+ await runCommandWithEvents("openclaw", ["plugins", "enable", modeConfig.pluginId], { onEvent });
287
+
288
+ // Safe to set plugins.allow only after install+enable — registry must know the plugin id.
289
+ mergePluginsAllowlist(modeConfig);
290
+
291
+ const list = await runCommandWithEvents("openclaw", ["plugins", "list"], { onEvent });
292
+ const doctor = await runCommandWithEvents("openclaw", ["plugins", "doctor"], { onEvent });
293
+ const pluginFound = `${list.stdout || ""}\n${list.stderr || ""}`.toLowerCase().includes(modeConfig.pluginId.toLowerCase());
294
+ if (!pluginFound) {
295
+ throw new Error(
296
+ `Plugin '${modeConfig.pluginId}' was not found in 'openclaw plugins list' after install/enable.`,
297
+ );
298
+ }
299
+ return {
300
+ installed: true,
301
+ enabled: true,
302
+ verified: true,
303
+ recoveredExistingDir,
304
+ list: list.stdout || "",
305
+ doctor: doctor.stdout || "",
306
+ };
307
+ }
308
+
309
+ function seedPluginConfig(modeConfig, orchestratorUrl, configPath = CONFIG_FILE) {
310
+ const defaultUrl = orchestratorUrl || "https://api.traderclaw.ai";
311
+
312
+ let config = {};
313
+ try {
314
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
315
+ } catch {
316
+ config = {};
317
+ }
318
+
319
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
320
+ if (!config.plugins.entries || typeof config.plugins.entries !== "object") config.plugins.entries = {};
321
+
322
+ const entries = config.plugins.entries;
323
+
324
+ const mergeOrchestratorForId = (pluginId) => {
325
+ const existing = entries[pluginId];
326
+ const existingConfig = existing && typeof existing === "object" && existing.config && typeof existing.config === "object"
327
+ ? existing.config
328
+ : {};
329
+ const url = typeof existingConfig.orchestratorUrl === "string" && existingConfig.orchestratorUrl.trim()
330
+ ? existingConfig.orchestratorUrl.trim()
331
+ : defaultUrl;
332
+ entries[pluginId] = {
333
+ enabled: existing && typeof existing.enabled === "boolean" ? existing.enabled : true,
334
+ config: {
335
+ ...existingConfig,
336
+ orchestratorUrl: url,
337
+ },
338
+ };
339
+ };
340
+
341
+ mergeOrchestratorForId(modeConfig.pluginId);
342
+ for (const legacyId of LEGACY_TRADER_PLUGIN_IDS) {
343
+ if (entries[legacyId]) mergeOrchestratorForId(legacyId);
344
+ }
345
+
346
+ // Do not set plugins.allow here: OpenClaw validates allow[] against the plugin registry, and
347
+ // the id is not registered until after `openclaw plugins install`. Pre-seeding allow caused:
348
+ // "plugins.allow: plugin not found: <id>".
349
+
350
+ mkdirSync(CONFIG_DIR, { recursive: true });
351
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
352
+ return configPath;
353
+ }
354
+
355
+ /**
356
+ * Resolve OpenClaw cron job store path (same rules as Gateway: optional cron.store, ~ expansion).
357
+ * @param {Record<string, unknown>} config
358
+ * @returns {string}
359
+ */
360
+ function resolveCronJobsStorePath(config) {
361
+ const raw = config?.cron?.store;
362
+ if (typeof raw === "string" && raw.trim()) {
363
+ let t = raw.trim();
364
+ if (t.startsWith("~")) {
365
+ t =
366
+ t === "~" || t === "~/" ? homedir() : join(homedir(), t.slice(2).replace(/^\/+/, ""));
367
+ }
368
+ if (t.startsWith("/") || (process.platform === "win32" && /^[A-Za-z]:[\\/]/.test(t))) {
369
+ return t;
370
+ }
371
+ return join(CONFIG_DIR, t);
372
+ }
373
+ return join(CONFIG_DIR, "cron", "jobs.json");
374
+ }
375
+
376
+ function cronJobStableId(job) {
377
+ if (!job || typeof job !== "object") return "";
378
+ const id = typeof job.id === "string" ? job.id.trim() : "";
379
+ if (id) return id;
380
+ const legacy = typeof job.jobId === "string" ? job.jobId.trim() : "";
381
+ return legacy;
382
+ }
383
+
384
+ /**
385
+ * Build a cron job record compatible with OpenClaw 2026+ store normalization (see ~/.openclaw/cron/jobs.json).
386
+ * @param {{ id: string, schedule: string, agentId: string, message: string, enabled?: boolean }} def
387
+ */
388
+ function buildOpenClawCronStoreJob(def) {
389
+ const nameFromId = def.id
390
+ .split("-")
391
+ .map((w) => (w.length ? w.charAt(0).toUpperCase() + w.slice(1) : w))
392
+ .join(" ");
393
+ return {
394
+ id: def.id,
395
+ name: nameFromId.length <= 60 ? nameFromId : nameFromId.slice(0, 59) + "…",
396
+ enabled: def.enabled !== false,
397
+ schedule: { kind: "cron", expr: def.schedule },
398
+ sessionTarget: "isolated",
399
+ wakeMode: "now",
400
+ agentId: def.agentId,
401
+ payload: {
402
+ kind: "agentTurn",
403
+ message: def.message,
404
+ lightContext: true,
405
+ },
406
+ // OpenClaw: "none" = no channel post; announce + last = summary to user's last chat (see OpenClaw cron delivery docs)
407
+ delivery: { mode: "announce", channel: "last", bestEffort: true },
408
+ state: {},
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Merge TraderClaw template cron jobs into the Gateway cron store (upsert by job id).
414
+ * Preserves user-defined jobs whose ids are not in the template set.
415
+ * @returns {{ storePath: string, added: number, updated: number, preserved: number, totalManaged: number }}
416
+ */
417
+ function mergeTraderCronJobsIntoStore(storePath, templateJobs) {
418
+ const managedIds = new Set(templateJobs.map((j) => j.id).filter(Boolean));
419
+ let existing = { version: 1, jobs: [] };
420
+ try {
421
+ const raw = readFileSync(storePath, "utf-8");
422
+ const parsed = JSON.parse(raw);
423
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
424
+ const jobs = Array.isArray(parsed.jobs) ? parsed.jobs.filter(Boolean) : [];
425
+ existing = { version: 1, jobs };
426
+ }
427
+ } catch (err) {
428
+ if (err && err.code === "ENOENT") {
429
+ // New store file — only TraderClaw template jobs.
430
+ } else {
431
+ return {
432
+ storePath,
433
+ added: 0,
434
+ updated: 0,
435
+ preserved: 0,
436
+ totalManaged: templateJobs.length,
437
+ error: err?.message || String(err),
438
+ wrote: false,
439
+ };
440
+ }
441
+ }
442
+
443
+ const beforeKeys = new Set();
444
+ for (const j of existing.jobs) {
445
+ const k = cronJobStableId(j);
446
+ if (k) beforeKeys.add(k);
447
+ }
448
+
449
+ const preserved = existing.jobs.filter((j) => !managedIds.has(cronJobStableId(j)));
450
+ const built = templateJobs.map((def) => buildOpenClawCronStoreJob(def));
451
+ const next = { version: 1, jobs: [...preserved, ...built] };
452
+
453
+ let added = 0;
454
+ let updated = 0;
455
+ for (const id of managedIds) {
456
+ if (beforeKeys.has(id)) updated += 1;
457
+ else added += 1;
458
+ }
459
+
460
+ const dir = dirname(storePath);
461
+ mkdirSync(dir, { recursive: true });
462
+ const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
463
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", "utf-8");
464
+ renameSync(tmp, storePath);
465
+
466
+ return {
467
+ storePath,
468
+ added,
469
+ updated,
470
+ preserved: preserved.length,
471
+ totalManaged: templateJobs.length,
472
+ wrote: true,
473
+ };
474
+ }
475
+
476
+ function mergePluginsAllowlist(modeConfig, configPath = CONFIG_FILE) {
477
+ let config = {};
478
+ try {
479
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
480
+ } catch {
481
+ return;
482
+ }
483
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
484
+ const allowSet = new Set(
485
+ Array.isArray(config.plugins.allow) ? config.plugins.allow.filter((id) => typeof id === "string" && id.trim()) : [],
486
+ );
487
+ allowSet.add(modeConfig.pluginId);
488
+ config.plugins.allow = [...allowSet];
489
+ mkdirSync(CONFIG_DIR, { recursive: true });
490
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
491
+ }
492
+
493
+ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
494
+ let config = {};
495
+ try {
496
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
497
+ } catch {
498
+ config = {};
499
+ }
500
+
501
+ if (!config.agents || typeof config.agents !== "object") config.agents = {};
502
+
503
+ const isV2 = modeConfig.pluginId === "solana-trader-v2";
504
+
505
+ const heartbeatPrompt =
506
+ "Read HEARTBEAT.md (workspace context). Follow it strictly — execute the full trading cycle and report results to the user. Do NOT reply HEARTBEAT_OK. Always produce a visible summary of what you checked and did.";
507
+
508
+ /** Default periodic wake interval for TraderClaw installs (was 5m; stretched to reduce load). */
509
+ const defaultHeartbeatEvery = "30m";
510
+
511
+ const v1Agents = [
512
+ { id: "main", default: true, heartbeat: { every: defaultHeartbeatEvery, target: "last", prompt: heartbeatPrompt } }
513
+ ];
514
+ const v2Agents = [
515
+ { id: "cto", default: true, heartbeat: { every: defaultHeartbeatEvery, target: "last", prompt: heartbeatPrompt } },
516
+ { id: "execution-specialist", heartbeat: { every: defaultHeartbeatEvery, target: "last", prompt: heartbeatPrompt } },
517
+ { id: "alpha-signal-analyst", heartbeat: { every: defaultHeartbeatEvery, target: "last", prompt: heartbeatPrompt } },
518
+ { id: "onchain-analyst" },
519
+ { id: "social-analyst" },
520
+ { id: "smart-money-tracker" },
521
+ { id: "risk-officer" },
522
+ { id: "strategy-researcher" }
523
+ ];
524
+
525
+ const targetAgents = isV2 ? v2Agents : v1Agents;
526
+
527
+ if (!Array.isArray(config.agents.list)) {
528
+ config.agents.list = [];
529
+ }
530
+ config.agents.list = config.agents.list.filter(a => a && typeof a === "object" && a.id);
531
+
532
+ const existingIds = new Set(config.agents.list.map(a => a.id));
533
+ for (const agent of targetAgents) {
534
+ if (existingIds.has(agent.id)) {
535
+ const existing = config.agents.list.find(a => a.id === agent.id);
536
+ if (agent.heartbeat) {
537
+ existing.heartbeat = agent.heartbeat;
538
+ }
539
+ if (agent.default) {
540
+ existing.default = true;
541
+ }
542
+ } else {
543
+ config.agents.list.push(agent);
544
+ }
545
+ }
546
+
547
+ if (!config.cron || typeof config.cron !== "object") {
548
+ config.cron = {};
549
+ }
550
+ config.cron.enabled = true;
551
+ if (!config.cron.maxConcurrentRuns) config.cron.maxConcurrentRuns = isV2 ? 3 : 2;
552
+ if (!config.cron.sessionRetention) config.cron.sessionRetention = "24h";
553
+
554
+ const mainAgent = isV2 ? "cto" : "main";
555
+
556
+ const v1Jobs = [
557
+ { id: "strategy-evolution", schedule: "0 */4 * * *", agentId: mainAgent, message: "CRON_JOB: strategy_evolution — Review trade journal, compute weight adjustments, update strategy. Only update if sufficient closed trades have accumulated.", enabled: true },
558
+ { id: "source-reputation", schedule: "0 */3 * * *", agentId: mainAgent, message: "CRON_JOB: source_reputation_recalc — Analyze which alpha signal sources led to wins vs losses. Update reputation tracking in memory.", enabled: true },
559
+ { id: "risk-audit", schedule: "0 */2 * * *", agentId: mainAgent, message: "CRON_JOB: portfolio_risk_audit — Portfolio stress tests, exposure checks, correlation analysis, drawdown monitoring.", enabled: true },
560
+ { id: "meta-rotation", schedule: "30 */3 * * *", agentId: mainAgent, message: "CRON_JOB: meta_rotation_analysis — Analyze narrative clusters across recent scans and trades. Identify hot vs cooling metas. Write observations to memory.", enabled: true },
561
+ { id: "dead-money-sweep", schedule: "0 */2 * * *", agentId: mainAgent, message: "CRON_JOB: dead_money_sweep — Check all open LOCAL_MANAGED positions for dead money. Exit stale positions. Tag as dead_money.", enabled: true },
562
+ { id: "subscription-cleanup", schedule: "0 * * * *", agentId: mainAgent, message: "CRON_JOB: subscription_cleanup — Check active Bitquery subscriptions. Unsubscribe from streams no longer needed (sold tokens, closed positions).", enabled: true },
563
+ { id: "daily-report", schedule: "0 4 * * *", agentId: mainAgent, message: "CRON_JOB: daily_performance_report — Calculate daily PnL, aggregate win/loss stats, source reputation summary, write comprehensive memory entry.", enabled: true },
564
+ { id: "whale-watch", schedule: "0 */2 * * *", agentId: mainAgent, message: "CRON_JOB: whale_activity_scan — Scan for large wallet movements, deployer activity, and accumulation patterns across watched tokens.", enabled: true }
565
+ ];
566
+
567
+ const v2ExtraJobs = [
568
+ { id: "whale-watch", schedule: "0 */2 * * *", agentId: "smart-money-tracker", message: "CRON_JOB: whale_activity_scan — Scan for large wallet movements, deployer activity, accumulation patterns. Detect smart money consensus and fresh wallet surges.", enabled: true },
569
+ { id: "sentiment-trend", schedule: "0 */4 * * *", agentId: "social-analyst", message: "CRON_JOB: sentiment_trend_analysis — Analyze Twitter/X trending tokens, influencer clustering, mention velocity, narrative exhaustion signals. Write to memory.", enabled: true },
570
+ { id: "execution-health", schedule: "0 */6 * * *", agentId: "execution-specialist", message: "CRON_JOB: execution_health_review — Review recent trade executions for slippage patterns, timing efficiency, failed transactions. Report execution quality metrics.", enabled: true },
571
+ { id: "source-reputation", schedule: "0 */3 * * *", agentId: "alpha-signal-analyst", message: "CRON_JOB: source_reputation_recalc — Analyze which alpha signal sources led to wins vs losses. Update reputation tracking in memory.", enabled: true },
572
+ { id: "risk-audit", schedule: "0 */2 * * *", agentId: "risk-officer", message: "CRON_JOB: portfolio_risk_audit — Portfolio stress tests, exposure checks, correlation analysis, drawdown monitoring. Produce risk report for CTO.", enabled: true },
573
+ { id: "strategy-evolution", schedule: "0 */4 * * *", agentId: "strategy-researcher", message: "CRON_JOB: strategy_evolution — Review trade journal, compute weight adjustments, update strategy. Only update if sufficient closed trades have accumulated.", enabled: true },
574
+ { id: "meta-rotation", schedule: "30 */3 * * *", agentId: "onchain-analyst", message: "CRON_JOB: meta_rotation_analysis — Analyze narrative clusters across recent scans and trades. Identify hot vs cooling metas. Write observations to memory.", enabled: true }
575
+ ];
576
+
577
+ const targetJobs = isV2
578
+ ? [...v1Jobs.filter(j => j.id === "dead-money-sweep" || j.id === "subscription-cleanup" || j.id === "daily-report"), ...v2ExtraJobs]
579
+ : v1Jobs;
580
+
581
+ let removedLegacyCronJobs = false;
582
+ if (config.cron && Object.prototype.hasOwnProperty.call(config.cron, "jobs")) {
583
+ // OpenClaw now stores jobs under ~/.openclaw/cron/jobs.json.
584
+ // Keeping cron.jobs in openclaw.json can fail strict config validation.
585
+ delete config.cron.jobs;
586
+ removedLegacyCronJobs = true;
587
+ }
588
+
589
+ if (!config.hooks || typeof config.hooks !== "object") {
590
+ config.hooks = {};
591
+ }
592
+ config.hooks.enabled = true;
593
+ if (!config.hooks.token || config.hooks.token === "shared-secret" || config.hooks.token === "REPLACE_WITH_SECURE_TOKEN") {
594
+ config.hooks.token = "hk_" + randomBytes(24).toString("hex");
595
+ }
596
+
597
+ const alphaAgentId = isV2 ? "alpha-signal-analyst" : "main";
598
+ const onchainAgentId = isV2 ? "onchain-analyst" : "main";
599
+
600
+ const targetMappings = [
601
+ { match: { path: "alpha-signal" }, action: "agent", agentId: alphaAgentId, deliver: true },
602
+ { match: { path: "firehose-alert" }, action: "agent", agentId: onchainAgentId, deliver: true }
603
+ ];
604
+
605
+ if (!Array.isArray(config.hooks.mappings)) {
606
+ config.hooks.mappings = [];
607
+ }
608
+ config.hooks.mappings = config.hooks.mappings.filter(m => m && typeof m === "object");
609
+
610
+ for (const mapping of targetMappings) {
611
+ const existingIdx = config.hooks.mappings.findIndex(m => m?.match?.path === mapping.match.path);
612
+ if (existingIdx >= 0) {
613
+ config.hooks.mappings[existingIdx] = mapping;
614
+ } else {
615
+ config.hooks.mappings.push(mapping);
616
+ }
617
+ }
618
+
619
+ mkdirSync(CONFIG_DIR, { recursive: true });
620
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
621
+
622
+ const cronStorePath = resolveCronJobsStorePath(config);
623
+ const cronMerge = mergeTraderCronJobsIntoStore(cronStorePath, targetJobs);
624
+
625
+ return {
626
+ configPath,
627
+ agentsConfigured: targetAgents.length,
628
+ cronJobsAdded: cronMerge.added,
629
+ cronJobsUpdated: cronMerge.updated,
630
+ cronJobsTotal: targetJobs.length,
631
+ cronJobsStorePath: cronMerge.storePath,
632
+ cronJobsStoreWriteOk: cronMerge.wrote === true,
633
+ cronJobsStoreError: cronMerge.error,
634
+ removedLegacyCronJobs,
635
+ hooksConfigured: config.hooks.mappings.length,
636
+ isV2,
637
+ };
638
+ }
639
+
640
+ function ensureOpenResponsesEnabled(configPath = CONFIG_FILE) {
641
+ let config = {};
642
+ try {
643
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
644
+ } catch {
645
+ config = {};
646
+ }
647
+
648
+ if (!config.gateway) config.gateway = {};
649
+ if (!config.gateway.http) config.gateway.http = {};
650
+ if (!config.gateway.http.endpoints) config.gateway.http.endpoints = {};
651
+ if (!config.gateway.http.endpoints.responses) config.gateway.http.endpoints.responses = {};
652
+ config.gateway.http.endpoints.responses.enabled = true;
653
+
654
+ mkdirSync(CONFIG_DIR, { recursive: true });
655
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
656
+ return configPath;
657
+ }
658
+
659
+ async function restartGateway() {
660
+ if (!commandExists("openclaw")) return { ran: false };
661
+ try {
662
+ await runCommandWithEvents("openclaw", ["gateway", "restart"]);
663
+ return { ran: true, success: true };
664
+ } catch {
665
+ return { ran: true, success: false };
666
+ }
667
+ }
668
+
669
+ function deployGatewayConfig(modeConfig) {
670
+ const gatewayDir = join(CONFIG_DIR, "gateway");
671
+ mkdirSync(gatewayDir, { recursive: true });
672
+ const destFile = join(gatewayDir, modeConfig.gatewayConfig);
673
+ const npmRoot = getCommandOutput("npm root -g");
674
+ if (!npmRoot) return { deployed: false, dest: destFile };
675
+ const src = join(npmRoot, modeConfig.pluginPackage, "config", modeConfig.gatewayConfig);
676
+ if (!existsSync(src)) return { deployed: false, dest: destFile };
677
+ writeFileSync(destFile, readFileSync(src));
678
+ return { deployed: true, source: src, dest: destFile };
679
+ }
680
+
681
+ function expandHomePath(p) {
682
+ if (typeof p !== "string" || !p.trim()) return null;
683
+ let t = p.trim();
684
+ if (t.startsWith("~")) {
685
+ t = t === "~" || t === "~/" ? homedir() : join(homedir(), t.slice(2).replace(/^\/+/, ""));
686
+ }
687
+ return t;
688
+ }
689
+
690
+ /**
691
+ * OpenClaw loads HEARTBEAT.md only from the agent workspace root (default ~/.openclaw/workspace).
692
+ * See https://docs.openclaw.ai/concepts/agent-workspace
693
+ */
694
+ export function resolveAgentWorkspaceDir(configPath = CONFIG_FILE) {
695
+ let config = {};
696
+ try {
697
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
698
+ } catch {
699
+ config = {};
700
+ }
701
+ const raw =
702
+ (typeof config.agents?.defaults?.workspace === "string" && config.agents.defaults.workspace.trim()) ||
703
+ (typeof config.agent?.workspace === "string" && config.agent.workspace.trim()) ||
704
+ "";
705
+ if (raw) {
706
+ const expanded = expandHomePath(raw);
707
+ if (expanded) return expanded;
708
+ }
709
+ return join(homedir(), ".openclaw", "workspace");
710
+ }
711
+
712
+ /**
713
+ * Copy skills/solana-trader/HEARTBEAT.md from the globally installed npm package into the workspace root.
714
+ * Skips overwrite if a non-empty file already exists (user may have customized it).
715
+ */
716
+ export function deployWorkspaceHeartbeat(modeConfig) {
717
+ const npmRoot = getCommandOutput("npm root -g");
718
+ if (!npmRoot) return { deployed: false, reason: "npm_root_g_failed" };
719
+ const src = join(npmRoot, modeConfig.pluginPackage, "skills", "solana-trader", "HEARTBEAT.md");
720
+ if (!existsSync(src)) return { deployed: false, reason: "source_missing", src };
721
+
722
+ const workspaceDir = resolveAgentWorkspaceDir(CONFIG_FILE);
723
+ const dest = join(workspaceDir, "HEARTBEAT.md");
724
+ mkdirSync(workspaceDir, { recursive: true });
725
+
726
+ if (existsSync(dest)) {
727
+ try {
728
+ if (statSync(dest).size > 0) {
729
+ return { deployed: false, skipped: true, reason: "already_exists_nonempty", dest };
730
+ }
731
+ } catch {
732
+ // overwrite empty or unreadable
733
+ }
734
+ }
735
+ writeFileSync(dest, readFileSync(src, "utf-8"), "utf-8");
736
+ return { deployed: true, skipped: false, source: src, dest };
737
+ }
738
+
739
+ function listProviderModels(provider) {
740
+ const cmd = `openclaw models list --all --provider ${shellQuote(provider)} --json`;
741
+ const raw = getCommandOutput(cmd);
742
+ if (!raw) return [];
743
+ try {
744
+ const parsed = JSON.parse(raw);
745
+ const models = Array.isArray(parsed?.models) ? parsed.models : [];
746
+ return models
747
+ .map((entry) => (entry && typeof entry.key === "string" ? entry.key : ""))
748
+ .filter((id) => id.startsWith(`${provider}/`));
749
+ } catch {
750
+ return [];
751
+ }
752
+ }
753
+
754
+ function fallbackModelForProvider(provider) {
755
+ // When `openclaw models list` fails, use current API ids (verify vs provider docs periodically).
756
+ if (provider === "anthropic") return "anthropic/claude-sonnet-4-6";
757
+ if (provider === "openai") return "openai/gpt-5.4";
758
+ if (provider === "openai-codex") return "openai-codex/gpt-5.4";
759
+ if (provider === "google" || provider === "google-vertex") return "google/gemini-2.5-flash";
760
+ return `${provider}/default`;
761
+ }
762
+
763
+ function providerEnvKey(provider) {
764
+ if (provider === "anthropic") return "ANTHROPIC_API_KEY";
765
+ if (provider === "openai" || provider === "openai-codex") return "OPENAI_API_KEY";
766
+ if (provider === "openrouter") return "OPENROUTER_API_KEY";
767
+ if (provider === "groq") return "GROQ_API_KEY";
768
+ if (provider === "mistral") return "MISTRAL_API_KEY";
769
+ if (provider === "google" || provider === "google-vertex") return "GEMINI_API_KEY";
770
+ return "";
771
+ }
772
+
773
+ function resolveLlmModelSelection(provider, requestedModel) {
774
+ const availableModels = listProviderModels(provider);
775
+ const warnings = [];
776
+
777
+ if (requestedModel) {
778
+ if (!requestedModel.startsWith(`${provider}/`)) {
779
+ warnings.push(`Manual model '${requestedModel}' does not match provider '${provider}'. Using provider default instead.`);
780
+ } else if (availableModels.length === 0 || availableModels.includes(requestedModel)) {
781
+ return { model: requestedModel, source: "manual", availableModels, warnings };
782
+ } else {
783
+ warnings.push(`Manual model '${requestedModel}' was not found in OpenClaw catalog for '${provider}'. Falling back to provider default.`);
784
+ }
785
+ }
786
+
787
+ if (availableModels.length > 0) {
788
+ const chosen = choosePreferredProviderModel(provider, availableModels);
789
+ if (chosen && availableModels.length > 1) {
790
+ warnings.push(`Auto-selected '${chosen}' as default model (${availableModels.length} models in catalog).`);
791
+ }
792
+ return { model: chosen || availableModels[0], source: "provider_default", availableModels, warnings };
793
+ }
794
+
795
+ warnings.push(`No discoverable model list found for provider '${provider}'. Falling back to '${fallbackModelForProvider(provider)}'.`);
796
+ return { model: fallbackModelForProvider(provider), source: "fallback_guess", availableModels, warnings };
797
+ }
798
+
799
+ function configureOpenClawLlmProvider({ provider, model, credential }, configPath = CONFIG_FILE) {
800
+ if (!provider || !credential) {
801
+ throw new Error("LLM provider and credential are required.");
802
+ }
803
+ if (!model) {
804
+ throw new Error("LLM model could not be resolved for the selected provider.");
805
+ }
806
+ if (!model.startsWith(`${provider}/`)) {
807
+ throw new Error(`Selected model '${model}' does not match provider '${provider}'.`);
808
+ }
809
+
810
+ let config = {};
811
+ try {
812
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
813
+ } catch {
814
+ config = {};
815
+ }
816
+
817
+ const envKey = providerEnvKey(provider);
818
+ if (!envKey) {
819
+ throw new Error(
820
+ `Provider '${provider}' is not supported by quick API-key setup in this wizard yet. Use a supported provider.`,
821
+ );
822
+ }
823
+
824
+ if (!config.env || typeof config.env !== "object") config.env = {};
825
+ config.env[envKey] = credential;
826
+
827
+ // Clean stale/broken provider objects from previous buggy writes.
828
+ if (config.models && config.models.providers && config.models.providers[provider]) {
829
+ delete config.models.providers[provider];
830
+ if (Object.keys(config.models.providers).length === 0) {
831
+ delete config.models.providers;
832
+ }
833
+ if (Object.keys(config.models).length === 0) {
834
+ delete config.models;
835
+ }
836
+ }
837
+
838
+ if (!config.agents) config.agents = {};
839
+ if (!config.agents.defaults) config.agents.defaults = {};
840
+ // OpenClaw 2026+ Zod schema requires agents.defaults.heartbeat whenever defaults exists
841
+ // (see OpenClaw AgentDefaultsSchema). Omitting it makes openclaw plugins install fail at
842
+ // writeConfigFile → validateConfigObjectRaw with a stack-only error in the UI.
843
+ if (!config.agents.defaults.heartbeat || typeof config.agents.defaults.heartbeat !== "object") {
844
+ config.agents.defaults.heartbeat = {};
845
+ }
846
+ if (!config.agents.defaults.model || typeof config.agents.defaults.model !== "object") {
847
+ config.agents.defaults.model = {};
848
+ }
849
+ config.agents.defaults.model.primary = model;
850
+
851
+ mkdirSync(CONFIG_DIR, { recursive: true });
852
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
853
+ return { configPath, provider, model };
854
+ }
855
+
856
+ function verifyInstallation(modeConfig, apiKey) {
857
+ const gatewayFile = join(CONFIG_DIR, "gateway", modeConfig.gatewayConfig);
858
+ let llmConfigured = false;
859
+ let pluginActive = false;
860
+ try {
861
+ const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
862
+ const primaryModel = config?.agents?.defaults?.model?.primary;
863
+ llmConfigured = typeof primaryModel === "string" && primaryModel.length > 0;
864
+ } catch {
865
+ llmConfigured = false;
866
+ }
867
+ if (commandExists("openclaw")) {
868
+ const pluginList = getCommandOutput("openclaw plugins list") || "";
869
+ pluginActive = pluginList.toLowerCase().includes(modeConfig.pluginId.toLowerCase());
870
+ }
871
+ let heartbeatConfigured = false;
872
+ let cronConfigured = false;
873
+ try {
874
+ const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
875
+ const agentsList = config?.agents?.list;
876
+ if (Array.isArray(agentsList)) {
877
+ heartbeatConfigured = agentsList.some(a => a.heartbeat && a.heartbeat.every);
878
+ }
879
+ cronConfigured = config?.cron?.enabled === true;
880
+ } catch {
881
+ }
882
+
883
+ const persistSnap = getLinuxGatewayPersistenceSnapshot();
884
+ let persistOk = true;
885
+ let persistNote = "not Linux / WSL or loginctl unavailable";
886
+ if (persistSnap.eligible) {
887
+ persistOk = persistSnap.linger === true;
888
+ persistNote =
889
+ persistSnap.linger === true
890
+ ? "linger enabled"
891
+ : "run: traderclaw gateway ensure-persistent (or sudo loginctl enable-linger $USER)";
892
+ }
893
+
894
+ const workspaceRoot = resolveAgentWorkspaceDir();
895
+ const heartbeatInWorkspace = existsSync(join(workspaceRoot, "HEARTBEAT.md"));
896
+
897
+ return [
898
+ { label: "OpenClaw platform", ok: commandExists("openclaw"), note: "not in PATH" },
899
+ { label: `Trading CLI (${modeConfig.cliName})`, ok: commandExists(modeConfig.cliName), note: "not in PATH" },
900
+ { label: `OpenClaw plugin (${modeConfig.pluginId})`, ok: pluginActive, note: "not installed/enabled" },
901
+ { label: "Configuration file", ok: existsSync(CONFIG_FILE), note: "not created" },
902
+ { label: "LLM provider configured", ok: llmConfigured, note: "missing model provider credential" },
903
+ { label: "Gateway configuration", ok: existsSync(gatewayFile), note: "not found" },
904
+ { label: "Heartbeat scheduling", ok: heartbeatConfigured, note: "agent will not wake autonomously" },
905
+ { label: "Cron jobs configured", ok: cronConfigured, note: "scheduled maintenance jobs missing" },
906
+ { label: "API key configured", ok: !!apiKey, note: "needs setup" },
907
+ {
908
+ label: "Gateway survives SSH (systemd linger)",
909
+ ok: !persistSnap.eligible || persistOk,
910
+ note: persistNote,
911
+ },
912
+ {
913
+ label: "HEARTBEAT.md in workspace root",
914
+ ok: heartbeatInWorkspace,
915
+ note: heartbeatInWorkspace ? workspaceRoot : `expected ${join(workspaceRoot, "HEARTBEAT.md")}`,
916
+ },
917
+ ];
918
+ }
919
+
920
+ function nowIso() {
921
+ return new Date().toISOString();
922
+ }
923
+
924
+ const URL_REGEX = /https?:\/\/[^\s"')]+/g;
925
+ function firstUrl(text = "") {
926
+ const found = text.match(URL_REGEX);
927
+ return found?.[0] || null;
928
+ }
929
+
930
+ function normalizeLane(input) {
931
+ return input === "event-driven" ? "event-driven" : "quick-local";
932
+ }
933
+
934
+ export class InstallerStepEngine {
935
+ constructor(modeConfig, options = {}, hooks = {}) {
936
+ this.modeConfig = modeConfig;
937
+ this.options = {
938
+ lane: normalizeLane(options.lane),
939
+ llmProvider: options.llmProvider || "",
940
+ llmModel: options.llmModel || "",
941
+ llmCredential: options.llmCredential || "",
942
+ apiKey: options.apiKey || "",
943
+ orchestratorUrl: options.orchestratorUrl || "https://api.traderclaw.ai",
944
+ gatewayBaseUrl: options.gatewayBaseUrl || "",
945
+ gatewayToken: options.gatewayToken || "",
946
+ enableTelegram: options.enableTelegram === true,
947
+ telegramToken: options.telegramToken || "",
948
+ autoInstallDeps: options.autoInstallDeps !== false,
949
+ skipPreflight: options.skipPreflight === true,
950
+ skipInstallOpenClaw: options.skipInstallOpenClaw === true,
951
+ skipInstallPlugin: options.skipInstallPlugin === true,
952
+ skipTailscale: options.skipTailscale === true,
953
+ skipGatewayBootstrap: options.skipGatewayBootstrap === true,
954
+ skipGatewayConfig: options.skipGatewayConfig === true,
955
+ };
956
+ this.hooks = {
957
+ onStepEvent: typeof hooks.onStepEvent === "function" ? hooks.onStepEvent : () => {},
958
+ onLog: typeof hooks.onLog === "function" ? hooks.onLog : () => {},
959
+ };
960
+ this.state = {
961
+ startedAt: null,
962
+ completedAt: null,
963
+ status: "idle",
964
+ errors: [],
965
+ detected: { funnelUrl: null, tailscaleApprovalUrl: null },
966
+ stepResults: [],
967
+ verifyChecks: [],
968
+ setupHandoff: null,
969
+ autoRecovery: {
970
+ gatewayModeRecoveryAttempted: false,
971
+ gatewayModeRecoverySucceeded: false,
972
+ backupPath: null,
973
+ },
974
+ };
975
+ }
976
+
977
+ async runWithPrivilegeGuidance(stepId, cmd, args = [], customLines = []) {
978
+ try {
979
+ return await runCommandWithEvents(cmd, args, {
980
+ onEvent: (evt) => this.emitLog(stepId, evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
981
+ });
982
+ } catch (err) {
983
+ if (isPrivilegeError(err)) {
984
+ throw new Error(privilegeRemediationMessage(cmd, args, customLines));
985
+ }
986
+ throw err;
987
+ }
988
+ }
989
+
990
+ emitStep(stepId, status, detail = "") {
991
+ this.hooks.onStepEvent({ at: nowIso(), stepId, status, detail });
992
+ }
993
+
994
+ emitLog(stepId, level, text, urls = []) {
995
+ const clean = typeof text === "string" ? stripAnsi(text) : text;
996
+ this.hooks.onLog({ at: nowIso(), stepId, level, text: clean, urls });
997
+ }
998
+
999
+ async runStep(stepId, title, handler) {
1000
+ this.emitStep(stepId, "in_progress", title);
1001
+ const startedAt = nowIso();
1002
+ try {
1003
+ const result = await handler();
1004
+ this.state.stepResults.push({ stepId, title, status: "completed", startedAt, completedAt: nowIso(), result });
1005
+ this.emitStep(stepId, "completed", title);
1006
+ return result;
1007
+ } catch (err) {
1008
+ const detail = stripAnsi(err?.message || String(err));
1009
+ this.state.stepResults.push({ stepId, title, status: "failed", startedAt, completedAt: nowIso(), error: detail });
1010
+ this.state.errors.push({ stepId, error: detail });
1011
+ this.emitStep(stepId, "failed", detail);
1012
+ throw err;
1013
+ }
1014
+ }
1015
+
1016
+ async ensureTailscale() {
1017
+ if (commandExists("tailscale")) return { installed: true, alreadyInstalled: true };
1018
+ if (!this.options.autoInstallDeps) throw new Error("tailscale missing and auto-install disabled");
1019
+
1020
+ if (!isRootUser() && !canUseSudoWithoutPrompt()) {
1021
+ throw new Error(
1022
+ [
1023
+ "Tailscale is not installed and the installer cannot elevate privileges automatically.",
1024
+ "Run this command in your terminal, then click Start Installation again:",
1025
+ "sudo bash -lc 'curl -fsSL https://tailscale.com/install.sh | sh'",
1026
+ ].join("\n"),
1027
+ );
1028
+ }
1029
+
1030
+ try {
1031
+ if (isRootUser()) {
1032
+ await this.runWithPrivilegeGuidance("tailscale", "bash", ["-lc", "curl -fsSL https://tailscale.com/install.sh | sh"]);
1033
+ } else {
1034
+ await this.runWithPrivilegeGuidance("tailscale", "sudo", ["bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh"]);
1035
+ }
1036
+ } catch (err) {
1037
+ const message = `${err?.message || ""} ${err?.stderr || ""}`.toLowerCase();
1038
+ if (message.includes("sudo") || message.includes("password")) {
1039
+ throw new Error(
1040
+ [
1041
+ "Tailscale installation requires terminal sudo approval.",
1042
+ "Run this command in your terminal, then click Start Installation again:",
1043
+ "sudo bash -lc 'curl -fsSL https://tailscale.com/install.sh | sh'",
1044
+ ].join("\n"),
1045
+ );
1046
+ }
1047
+ throw err;
1048
+ }
1049
+
1050
+ return { installed: true, alreadyInstalled: false };
1051
+ }
1052
+
1053
+ async runTailscaleUp() {
1054
+ try {
1055
+ const result = await runCommandWithEvents("tailscale", ["up"], {
1056
+ onEvent: (evt) => {
1057
+ const url = firstUrl(evt.text);
1058
+ if (url && !this.state.detected.tailscaleApprovalUrl) this.state.detected.tailscaleApprovalUrl = url;
1059
+ this.emitLog("tailscale_up", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []);
1060
+ },
1061
+ });
1062
+ return { ok: true, approvalUrl: this.state.detected.tailscaleApprovalUrl, urls: result.urls || [] };
1063
+ } catch (err) {
1064
+ const details = `${err?.stderr || ""}\n${err?.stdout || ""}\n${err?.message || ""}`.toLowerCase();
1065
+ if (
1066
+ details.includes("access denied")
1067
+ || details.includes("checkprefs")
1068
+ || details.includes("prefs write access denied")
1069
+ ) {
1070
+ throw new Error(tailscalePermissionRemediation());
1071
+ }
1072
+ throw err;
1073
+ }
1074
+ }
1075
+
1076
+ async runFunnel() {
1077
+ try {
1078
+ await this.runWithPrivilegeGuidance("funnel", "tailscale", ["funnel", "--bg", "18789"]);
1079
+ } catch (err) {
1080
+ const details = `${err?.stderr || ""}\n${err?.stdout || ""}\n${err?.message || ""}`.toLowerCase();
1081
+ if (details.includes("access denied") || details.includes("operator")) {
1082
+ throw new Error(tailscalePermissionRemediation());
1083
+ }
1084
+ throw err;
1085
+ }
1086
+ const statusOut = getCommandOutput("tailscale funnel status") || "";
1087
+ const funnelUrl = firstUrl(statusOut);
1088
+ if (funnelUrl) this.state.detected.funnelUrl = funnelUrl;
1089
+ this.emitLog("funnel", "info", statusOut);
1090
+ return { funnelUrl };
1091
+ }
1092
+
1093
+ readGatewayStatusSnapshot() {
1094
+ const raw = getCommandOutput("openclaw gateway status --json || true");
1095
+ if (!raw) return null;
1096
+ try {
1097
+ return JSON.parse(raw);
1098
+ } catch {
1099
+ return null;
1100
+ }
1101
+ }
1102
+
1103
+ isGatewayHealthy(statusJson) {
1104
+ if (!statusJson || typeof statusJson !== "object") return false;
1105
+ const serviceStatus = statusJson?.service?.runtime?.status;
1106
+ const rpcOk = statusJson?.rpc?.ok === true;
1107
+ return serviceStatus === "running" && rpcOk;
1108
+ }
1109
+
1110
+ async tryAutoRecoverGatewayMode(stepId) {
1111
+ if (this.state.autoRecovery.gatewayModeRecoveryAttempted) {
1112
+ return { attempted: true, success: false, reason: "already_attempted" };
1113
+ }
1114
+ this.state.autoRecovery.gatewayModeRecoveryAttempted = true;
1115
+
1116
+ let config = {};
1117
+ let rawOriginal = "{}\n";
1118
+ try {
1119
+ rawOriginal = readFileSync(CONFIG_FILE, "utf-8");
1120
+ config = JSON.parse(rawOriginal);
1121
+ } catch {
1122
+ config = {};
1123
+ }
1124
+
1125
+ if (!config.gateway) config.gateway = {};
1126
+ const changed = [];
1127
+ if (!config.gateway.mode) {
1128
+ config.gateway.mode = "local";
1129
+ changed.push("gateway.mode=local");
1130
+ }
1131
+ if (!config.gateway.bind) {
1132
+ config.gateway.bind = "loopback";
1133
+ changed.push("gateway.bind=loopback");
1134
+ }
1135
+ if (!Number.isInteger(config.gateway.port)) {
1136
+ config.gateway.port = 18789;
1137
+ changed.push("gateway.port=18789");
1138
+ }
1139
+
1140
+ if (changed.length === 0) {
1141
+ return { attempted: true, success: false, reason: "no_missing_gateway_defaults" };
1142
+ }
1143
+
1144
+ mkdirSync(CONFIG_DIR, { recursive: true });
1145
+ const backupPath = `${CONFIG_FILE}.bak.${Date.now()}`;
1146
+ writeFileSync(backupPath, rawOriginal, "utf-8");
1147
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
1148
+ this.state.autoRecovery.backupPath = backupPath;
1149
+ this.emitLog(stepId, "warn", `Auto-recovery: applied ${changed.join(", ")} with backup at ${backupPath}`);
1150
+
1151
+ try {
1152
+ await this.runWithPrivilegeGuidance(stepId, "openclaw", ["gateway", "stop"]);
1153
+ } catch {
1154
+ // best effort stop
1155
+ }
1156
+ await this.runWithPrivilegeGuidance(stepId, "openclaw", ["gateway", "install"]);
1157
+ await this.runWithPrivilegeGuidance(stepId, "openclaw", ["gateway", "restart"]);
1158
+
1159
+ const status = this.readGatewayStatusSnapshot();
1160
+ const healthy = this.isGatewayHealthy(status);
1161
+ if (healthy) {
1162
+ this.state.autoRecovery.gatewayModeRecoverySucceeded = true;
1163
+ this.emitLog(stepId, "info", "Auto-recovery succeeded: gateway is healthy after restart.");
1164
+ return { attempted: true, success: true, backupPath };
1165
+ }
1166
+ return { attempted: true, success: false, backupPath, reason: "gateway_not_healthy_after_recovery" };
1167
+ }
1168
+
1169
+ async runTelegramStep() {
1170
+ if (!this.options.telegramToken) {
1171
+ throw new Error(
1172
+ "Telegram token is required for this installer flow. Add your bot token in the wizard and start again.",
1173
+ );
1174
+ }
1175
+ await runCommandWithEvents("openclaw", ["plugins", "enable", "telegram"]);
1176
+ await runCommandWithEvents("openclaw", ["channels", "add", "--channel", "telegram", "--token", this.options.telegramToken]);
1177
+ await runCommandWithEvents("openclaw", ["channels", "status", "--probe"]);
1178
+ const policy = ensureTelegramGroupPolicyOpenForWizard();
1179
+ if (policy.changed) {
1180
+ this.emitLog(
1181
+ "telegram_required",
1182
+ "info",
1183
+ "Set channels.telegram.groupPolicy=open (no sender allowlist yet) to avoid Doctor allowlist warnings on gateway restart. Tighten groupAllowFrom later if you use groups.",
1184
+ );
1185
+ }
1186
+ return { configured: true };
1187
+ }
1188
+
1189
+ async configureLlmStep() {
1190
+ const provider = String(this.options.llmProvider || "").trim();
1191
+ const requestedModel = String(this.options.llmModel || "").trim();
1192
+ const credential = String(this.options.llmCredential || "").trim();
1193
+ if (!provider || !credential) {
1194
+ throw new Error(
1195
+ "Missing required LLM settings. Select provider and provide credential in the wizard before starting installation.",
1196
+ );
1197
+ }
1198
+ if (!commandExists("openclaw")) {
1199
+ throw new Error("OpenClaw is not available yet. Install step must complete before LLM configuration.");
1200
+ }
1201
+
1202
+ const selection = resolveLlmModelSelection(provider, requestedModel);
1203
+ for (const msg of selection.warnings) {
1204
+ this.emitLog("configure_llm", "warn", msg);
1205
+ }
1206
+ const model = selection.model;
1207
+
1208
+ const saved = configureOpenClawLlmProvider({ provider, model, credential });
1209
+ this.emitLog("configure_llm", "info", `Configured OpenClaw model primary=${model}`);
1210
+
1211
+ await runCommandWithEvents("openclaw", ["config", "validate"], {
1212
+ onEvent: (evt) => this.emitLog("configure_llm", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1213
+ });
1214
+
1215
+ try {
1216
+ await runCommandWithEvents("openclaw", ["models", "status", "--check", "--probe-provider", provider], {
1217
+ onEvent: (evt) => this.emitLog("configure_llm", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1218
+ });
1219
+ } catch (err) {
1220
+ const details = `${err?.stderr || ""}\n${err?.stdout || ""}\n${err?.message || ""}`.trim();
1221
+ throw new Error(
1222
+ `LLM provider validation failed for '${provider}'. Check credential/model and retry.\n${details}`,
1223
+ );
1224
+ }
1225
+
1226
+ return { configured: true, provider, model, configPath: saved.configPath };
1227
+ }
1228
+
1229
+ buildSetupHandoff() {
1230
+ const args = ["setup", "--url", this.options.orchestratorUrl || "https://api.traderclaw.ai"];
1231
+ if (this.options.lane !== "event-driven") {
1232
+ args.push("--skip-gateway-registration");
1233
+ }
1234
+ const gatewayBaseUrl = this.options.gatewayBaseUrl || this.state.detected.funnelUrl || "";
1235
+ if (this.options.lane === "event-driven" && gatewayBaseUrl) {
1236
+ args.push("--gateway-base-url", gatewayBaseUrl);
1237
+ }
1238
+
1239
+ const command = [this.modeConfig.cliName, ...args].join(" ");
1240
+ const docs =
1241
+ "https://docs.traderclaw.ai/docs/installation#troubleshooting-session-expired-auth-errors-or-the-agent-logged-out";
1242
+ return {
1243
+ pending: true,
1244
+ command,
1245
+ title: "Ready to launch your agentic trading desk",
1246
+ message:
1247
+ "Core install is complete. Final setup is intentionally handed off to your VPS shell so sensitive wallet prompts stay private. " +
1248
+ "After setup, if the bot reports wallet proof / session errors: configure TRADERCLAW_WALLET_PRIVATE_KEY for the OpenClaw gateway service (systemd), not only in SSH — see " +
1249
+ docs,
1250
+ hint:
1251
+ "Run the command in terminal, answer setup prompts, then restart gateway. If Telegram startup checks all fail, open the troubleshooting link in the message above.",
1252
+ restartCommand: "openclaw gateway restart",
1253
+ };
1254
+ }
1255
+
1256
+ async runAll() {
1257
+ this.state.status = "running";
1258
+ this.state.startedAt = nowIso();
1259
+ try {
1260
+ if (!this.options.skipPreflight) {
1261
+ await this.runStep("preflight", "Checking prerequisites", async () => {
1262
+ if (!commandExists("node") || !commandExists("npm")) throw new Error("node and npm are required");
1263
+ return { node: true, npm: true, openclaw: commandExists("openclaw"), tailscale: commandExists("tailscale") };
1264
+ });
1265
+ }
1266
+
1267
+ if (!this.options.skipInstallOpenClaw) {
1268
+ await this.runStep("install_openclaw", "Installing OpenClaw platform", async () => installOpenClawPlatform());
1269
+ }
1270
+ await this.runStep("configure_llm", "Configuring required OpenClaw LLM provider", async () => this.configureLlmStep());
1271
+ if (!this.options.skipInstallPlugin) {
1272
+ await this.runStep("install_plugin_package", "Installing TraderClaw CLI package", async () =>
1273
+ installPlugin(
1274
+ this.modeConfig,
1275
+ (evt) => this.emitLog("install_plugin_package", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1276
+ ));
1277
+ await this.runStep(
1278
+ "activate_openclaw_plugin",
1279
+ "Installing and enabling TraderClaw inside OpenClaw",
1280
+ async () =>
1281
+ installAndEnableOpenClawPlugin(
1282
+ this.modeConfig,
1283
+ (evt) => this.emitLog("activate_openclaw_plugin", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1284
+ this.options.orchestratorUrl,
1285
+ ),
1286
+ );
1287
+ }
1288
+ if (!this.options.skipTailscale) {
1289
+ await this.runStep("tailscale_install", "Ensuring Tailscale is installed", async () => this.ensureTailscale());
1290
+ await this.runStep("tailscale_up", "Connecting Tailscale", async () => this.runTailscaleUp());
1291
+ }
1292
+ if (!this.options.skipGatewayBootstrap) {
1293
+ await this.runStep("gateway_bootstrap", "Starting OpenClaw gateway and Funnel", async () => {
1294
+ try {
1295
+ await this.runWithPrivilegeGuidance("gateway_bootstrap", "openclaw", ["gateway", "install"]);
1296
+ await this.runWithPrivilegeGuidance("gateway_bootstrap", "openclaw", ["gateway", "restart"]);
1297
+ return this.runFunnel();
1298
+ } catch (err) {
1299
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
1300
+ const gatewayModeUnset = text.includes("gateway.mode=local") && text.includes("current: unset");
1301
+ if (
1302
+ text.includes("gateway restart timed out")
1303
+ || text.includes("timed out after 60s waiting for health checks")
1304
+ || text.includes("waiting for gateway port")
1305
+ || gatewayModeUnset
1306
+ ) {
1307
+ const recovered = await this.tryAutoRecoverGatewayMode("gateway_bootstrap");
1308
+ if (recovered.success) {
1309
+ return this.runFunnel();
1310
+ }
1311
+ if (gatewayModeUnset) {
1312
+ throw new Error(gatewayModeUnsetRemediation());
1313
+ }
1314
+ throw new Error(gatewayTimeoutRemediation());
1315
+ }
1316
+ throw err;
1317
+ }
1318
+ });
1319
+ }
1320
+
1321
+ if (!this.options.skipGatewayBootstrap) {
1322
+ await this.runStep("gateway_persistence", "SSH-safe gateway (systemd user linger)", async () => {
1323
+ const { ensureLinuxGatewayPersistence } = await import("./gateway-persistence-linux.mjs");
1324
+ return ensureLinuxGatewayPersistence({
1325
+ emitLog: (level, text) => this.emitLog("gateway_persistence", level, text),
1326
+ runPrivileged: (cmd, args) => this.runWithPrivilegeGuidance("gateway_persistence", cmd, args),
1327
+ });
1328
+ });
1329
+ }
1330
+
1331
+ await this.runStep("enable_responses", "Enabling /v1/responses endpoint", async () => {
1332
+ const configPath = ensureOpenResponsesEnabled(CONFIG_FILE);
1333
+ const restart = await restartGateway();
1334
+ return { configPath, restart };
1335
+ });
1336
+
1337
+ await this.runStep("gateway_scheduling", "Configuring heartbeat and cron schedules", async () => {
1338
+ const result = configureGatewayScheduling(this.modeConfig, CONFIG_FILE);
1339
+ this.emitLog("gateway_scheduling", "info", `Agents configured: ${result.agentsConfigured}`);
1340
+ if (result.cronJobsStoreWriteOk) {
1341
+ this.emitLog(
1342
+ "gateway_scheduling",
1343
+ "info",
1344
+ `Cron store: ${result.cronJobsStorePath} (${result.cronJobsTotal} TraderClaw jobs; +${result.cronJobsAdded} new, ~${result.cronJobsUpdated} updated).`,
1345
+ );
1346
+ } else if (result.cronJobsStoreError) {
1347
+ this.emitLog(
1348
+ "gateway_scheduling",
1349
+ "warn",
1350
+ `Cron store not updated (${result.cronJobsStorePath}): ${result.cronJobsStoreError}`,
1351
+ );
1352
+ } else {
1353
+ this.emitLog("gateway_scheduling", "warn", "Cron store write did not complete; check permissions and disk space.");
1354
+ }
1355
+ if (result.removedLegacyCronJobs) {
1356
+ this.emitLog("gateway_scheduling", "warn", "Removed legacy 'cron.jobs' from openclaw.json to keep config validation compatible.");
1357
+ }
1358
+ this.emitLog("gateway_scheduling", "info", `Webhook hooks: ${result.hooksConfigured}`);
1359
+ const restart = await restartGateway();
1360
+ return { ...result, restart };
1361
+ });
1362
+
1363
+ await this.runStep("workspace_heartbeat", "Installing HEARTBEAT.md into agent workspace", async () => {
1364
+ const result = deployWorkspaceHeartbeat(this.modeConfig);
1365
+ if (result.deployed) {
1366
+ this.emitLog("workspace_heartbeat", "info", `Installed TraderClaw HEARTBEAT.md → ${result.dest}`);
1367
+ } else if (result.skipped) {
1368
+ this.emitLog(
1369
+ "workspace_heartbeat",
1370
+ "info",
1371
+ `HEARTBEAT.md already present at ${result.dest} — not overwriting (edit or delete to replace).`,
1372
+ );
1373
+ } else {
1374
+ this.emitLog(
1375
+ "workspace_heartbeat",
1376
+ "warn",
1377
+ `Could not install HEARTBEAT.md automatically (${result.reason || "unknown"})${result.src ? `. Expected: ${result.src}` : ""}`,
1378
+ );
1379
+ }
1380
+ return result;
1381
+ });
1382
+
1383
+ await this.runStep("setup_handoff", "Preparing secure setup handoff", async () => {
1384
+ const handoff = this.buildSetupHandoff();
1385
+ this.state.setupHandoff = handoff;
1386
+ this.emitLog("setup_handoff", "info", handoff.title);
1387
+ this.emitLog("setup_handoff", "info", handoff.message);
1388
+ this.emitLog("setup_handoff", "info", `Run in VPS shell: ${handoff.command}`);
1389
+ this.emitLog("setup_handoff", "info", `Then run: ${handoff.restartCommand}`);
1390
+ return handoff;
1391
+ });
1392
+
1393
+ if (!this.options.skipGatewayConfig) {
1394
+ await this.runStep("gateway_config", "Deploying gateway config and restarting", async () => {
1395
+ const deploy = deployGatewayConfig(this.modeConfig);
1396
+ const restart = await restartGateway();
1397
+ return { deploy, restart };
1398
+ });
1399
+ }
1400
+
1401
+ await this.runStep("telegram_required", "Configuring required Telegram channel", async () => this.runTelegramStep());
1402
+ await this.runStep("verify", "Verifying installation", async () => {
1403
+ const checks = verifyInstallation(this.modeConfig, this.options.apiKey);
1404
+ this.state.verifyChecks = checks;
1405
+ return { checks };
1406
+ });
1407
+
1408
+ this.state.status = "completed";
1409
+ this.state.completedAt = nowIso();
1410
+ return this.state;
1411
+ } catch (err) {
1412
+ this.state.status = "failed";
1413
+ this.state.completedAt = nowIso();
1414
+ this.state.errors.push({ stepId: "runtime", error: err?.message || String(err) });
1415
+ return this.state;
1416
+ }
1417
+ }
1418
+ }
1419
+
1420
+ export function createInstallerStepEngine(modeConfig, options = {}, hooks = {}) {
1421
+ return new InstallerStepEngine(modeConfig, options, hooks);
1422
+ }