horizon-code 0.1.2 → 0.3.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.
@@ -12,9 +12,32 @@ import {
12
12
  } from "@opentui/core";
13
13
  type TreeSitterClient = any;
14
14
  import { COLORS } from "../theme/colors.ts";
15
+ import { store } from "../state/store.ts";
16
+ import type { DeploymentMetrics, Position, Order } from "../state/types.ts";
15
17
 
16
18
  const h = (hex: string) => RGBA.fromHex(hex);
17
19
 
20
+ function sparkline(values: number[], width: number): string {
21
+ if (values.length < 2) return "";
22
+ const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
23
+ const min = Math.min(...values);
24
+ const max = Math.max(...values);
25
+ const range = max - min || 1;
26
+ const step = values.length / width;
27
+ let result = "";
28
+ for (let i = 0; i < width && i * step < values.length; i++) {
29
+ const idx = Math.min(Math.floor(i * step), values.length - 1);
30
+ const normalized = (values[idx]! - min) / range;
31
+ const charIdx = Math.min(Math.floor(normalized * (chars.length - 1)), chars.length - 1);
32
+ result += chars[charIdx];
33
+ }
34
+ return result;
35
+ }
36
+
37
+ function pad(s: string, width: number): string {
38
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
39
+ }
40
+
18
41
  // Python syntax colors (matches highlights.scm groups)
19
42
  const syntaxStyle = SyntaxStyle.fromStyles({
20
43
  "@keyword": { fg: h("#C586C0") }, // purple
@@ -36,7 +59,7 @@ const syntaxStyle = SyntaxStyle.fromStyles({
36
59
  "@punctuation.special": { fg: h("#D7BA7D") }, // gold
37
60
  });
38
61
 
39
- type PanelTab = "code" | "logs" | "dashboard";
62
+ type PanelTab = "code" | "logs" | "dashboard" | "widgets";
40
63
 
41
64
  export class CodePanel {
42
65
  readonly container: BoxRenderable;
@@ -51,6 +74,8 @@ export class CodePanel {
51
74
  private logsMd: MarkdownRenderable;
52
75
  private dashScroll: ScrollBoxRenderable;
53
76
  private dashMd: MarkdownRenderable;
77
+ private widgetsScroll: ScrollBoxRenderable;
78
+ private widgetsList: BoxRenderable;
54
79
 
55
80
  private footerText: TextRenderable;
56
81
  private _visible = false;
@@ -59,7 +84,16 @@ export class CodePanel {
59
84
  private _name = "";
60
85
  private _phase = "";
61
86
  private _logs = "";
62
- private _dashboardHtml = "";
87
+ private _metricsData: {
88
+ name: string;
89
+ status: string;
90
+ dryRun: boolean;
91
+ metrics: DeploymentMetrics;
92
+ positions: Position[];
93
+ orders: Order[];
94
+ pnlHistory: number[];
95
+ startedAt: number;
96
+ } | null = null;
63
97
  private _validationStatus: "pending" | "valid" | "invalid" | "none" = "none";
64
98
 
65
99
  constructor(private renderer: CliRenderer) {
@@ -95,7 +129,8 @@ export class CodePanel {
95
129
  const tabs: { id: PanelTab; label: string }[] = [
96
130
  { id: "code", label: "Code" },
97
131
  { id: "logs", label: "Logs" },
98
- { id: "dashboard", label: "Dashboard" },
132
+ { id: "dashboard", label: "Metrics" },
133
+ { id: "widgets", label: "Widgets" },
99
134
  ];
100
135
  for (const tab of tabs) {
101
136
  const text = new TextRenderable(renderer, {
@@ -157,16 +192,40 @@ export class CodePanel {
157
192
  this.dashScroll.visible = false;
158
193
  this.dashMd = new MarkdownRenderable(renderer, {
159
194
  id: "dash-md",
160
- content: "*no dashboard generated*\n\n*Ask the LLM to build a dashboard.*",
195
+ content: "*no active deployment*\n\n*Deploy a strategy to see live metrics here.*",
161
196
  syntaxStyle,
162
197
  });
163
198
  this.dashScroll.add(this.dashMd);
164
199
  this.container.add(this.dashScroll);
165
200
 
201
+ // ── Widgets tab content ──
202
+ this.widgetsScroll = new ScrollBoxRenderable(renderer, {
203
+ id: "widgets-scroll",
204
+ flexGrow: 1,
205
+ paddingLeft: 1,
206
+ paddingRight: 1,
207
+ paddingTop: 1,
208
+ stickyScroll: true,
209
+ stickyStart: "bottom",
210
+ });
211
+ this.widgetsScroll.visible = false;
212
+ this.widgetsList = new BoxRenderable(renderer, {
213
+ id: "widgets-list",
214
+ flexDirection: "column",
215
+ width: "100%",
216
+ });
217
+ this.widgetsList.add(new TextRenderable(renderer, {
218
+ id: "widgets-empty",
219
+ content: "*no widgets yet*\n\n*Tool results will appear here when \"Widgets in Tab\" is enabled in settings.*",
220
+ fg: COLORS.textMuted,
221
+ }));
222
+ this.widgetsScroll.add(this.widgetsList);
223
+ this.container.add(this.widgetsScroll);
224
+
166
225
  // ── Footer ──
167
226
  this.footerText = new TextRenderable(renderer, {
168
227
  id: "code-footer",
169
- content: " ^G close | Tab cycle | 1 code 2 logs 3 dash",
228
+ content: " ^G close | Tab cycle | 1 code 2 logs 3 dash 4 widgets",
170
229
  fg: COLORS.borderDim,
171
230
  });
172
231
  this.container.add(this.footerText);
@@ -192,20 +251,72 @@ export class CodePanel {
192
251
  this.codeScroll.visible = tab === "code";
193
252
  this.logsScroll.visible = tab === "logs";
194
253
  this.dashScroll.visible = tab === "dashboard";
254
+ this.widgetsScroll.visible = tab === "widgets";
195
255
  // Refresh content for the active tab
196
256
  if (tab === "code") this.updateCodeContent();
197
257
  else if (tab === "logs") this.updateLogsContent();
198
258
  else if (tab === "dashboard") this.updateDashContent();
259
+ // Widgets tab: force scroll to bottom to show latest
260
+ if (tab === "widgets") this.widgetsScroll.scrollToBottom?.();
199
261
  this.updateTabBar();
200
262
  this.renderer.requestRender();
201
263
  }
202
264
 
203
265
  cycleTab(): void {
204
- const order: PanelTab[] = ["code", "logs", "dashboard"];
266
+ const order: PanelTab[] = ["code", "logs", "dashboard", "widgets"];
205
267
  const idx = order.indexOf(this._activeTab);
206
268
  this.setTab(order[(idx + 1) % order.length]!);
207
269
  }
208
270
 
271
+ private _widgetCount = 0;
272
+
273
+ /** Add a widget renderable to the widgets tab */
274
+ addWidget(toolName: string, widget: BoxRenderable): void {
275
+ // Remove placeholder on first widget
276
+ if (this._widgetCount === 0) {
277
+ const children = this.widgetsList.getChildren();
278
+ for (const child of [...children]) {
279
+ this.widgetsList.remove(child);
280
+ }
281
+ }
282
+ this._widgetCount++;
283
+
284
+ // Separator between widgets
285
+ if (this._widgetCount > 1) {
286
+ this.widgetsList.add(new TextRenderable(this.renderer, {
287
+ id: `wt-sep-${Date.now()}-${this._widgetCount}`,
288
+ content: "",
289
+ fg: COLORS.borderDim,
290
+ }));
291
+ }
292
+
293
+ // Tool name header
294
+ this.widgetsList.add(new TextRenderable(this.renderer, {
295
+ id: `wt-hdr-${Date.now()}-${this._widgetCount}`,
296
+ content: `── ${toolName.replace(/_/g, " ")} ${"─".repeat(40)}`,
297
+ fg: COLORS.borderDim,
298
+ }));
299
+
300
+ // The widget itself
301
+ this.widgetsList.add(widget);
302
+
303
+ this.renderer.requestRender();
304
+ }
305
+
306
+ /** Clear all widgets from the tab */
307
+ clearWidgets(): void {
308
+ const children = this.widgetsList.getChildren();
309
+ for (const child of [...children]) {
310
+ this.widgetsList.remove(child);
311
+ }
312
+ this._widgetCount = 0;
313
+ this.widgetsList.add(new TextRenderable(this.renderer, {
314
+ id: `widgets-empty-${Date.now()}`,
315
+ content: "*no widgets yet*\n\n*Enable \"Widgets in Tab\" in /settings.*",
316
+ fg: COLORS.textMuted,
317
+ }));
318
+ }
319
+
209
320
  // ── Content setters ──
210
321
 
211
322
  setCode(code: string, status: "pending" | "valid" | "invalid" | "none" = "none"): void {
@@ -234,8 +345,17 @@ export class CodePanel {
234
345
  if (this._activeTab === "logs") this.updateLogsContent();
235
346
  }
236
347
 
237
- setDashboardHtml(html: string): void {
238
- this._dashboardHtml = html;
348
+ setMetrics(data: {
349
+ name: string;
350
+ status: string;
351
+ dryRun: boolean;
352
+ metrics: DeploymentMetrics;
353
+ positions: Position[];
354
+ orders: Order[];
355
+ pnlHistory: number[];
356
+ startedAt: number;
357
+ } | null): void {
358
+ this._metricsData = data;
239
359
  if (this._activeTab === "dashboard") this.updateDashContent();
240
360
  }
241
361
 
@@ -262,16 +382,17 @@ export class CodePanel {
262
382
 
263
383
  private updateTabBar(): void {
264
384
  for (const [id, text] of this.tabTexts) {
385
+ const label = id === "code" ? (this._name || "Code")
386
+ : id === "dashboard" ? "Metrics"
387
+ : id.charAt(0).toUpperCase() + id.slice(1);
265
388
  if (id === this._activeTab) {
266
389
  text.fg = "#212121";
267
390
  text.bg = COLORS.accent;
268
- text.content = id === "code"
269
- ? ` ${this._name || "Code"} `
270
- : ` ${id.charAt(0).toUpperCase() + id.slice(1)} `;
391
+ text.content = ` ${label} `;
271
392
  } else {
272
393
  text.fg = COLORS.textMuted;
273
394
  text.bg = undefined;
274
- text.content = ` ${id.charAt(0).toUpperCase() + id.slice(1)} `;
395
+ text.content = ` ${label} `;
275
396
  }
276
397
  }
277
398
  }
@@ -304,7 +425,20 @@ export class CodePanel {
304
425
  this.codeMd.content = "*no strategy loaded*\n\n*Describe a strategy in the chat to get started.*";
305
426
  }
306
427
  } else {
307
- this.codeMd.content = "```python\n" + this._code + "\n```";
428
+ let content = "```python\n" + this._code + "\n```";
429
+
430
+ // Show warnings if any
431
+ const draft = store.getActiveSession()?.strategyDraft;
432
+ if (draft?.validationWarnings?.length) {
433
+ content += "\n\n---\n";
434
+ for (const w of draft.validationWarnings) {
435
+ const icon = w.severity === "warning" ? "!" : "i";
436
+ const lineRef = w.line ? ` (line ${w.line})` : "";
437
+ content += `\n${icon} ${w.message}${lineRef}`;
438
+ }
439
+ }
440
+
441
+ this.codeMd.content = content;
308
442
  }
309
443
  this.renderer.requestRender();
310
444
  }
@@ -319,11 +453,83 @@ export class CodePanel {
319
453
  }
320
454
 
321
455
  private updateDashContent(): void {
322
- if (!this._dashboardHtml) {
323
- this.dashMd.content = "*no dashboard generated*\n\n*Ask the LLM to build a dashboard.*";
324
- } else {
325
- this.dashMd.content = "```html\n" + this._dashboardHtml + "\n```";
456
+ if (!this._metricsData) {
457
+ this.dashMd.content = "*no active deployment*\n\n*Deploy a strategy to see live metrics here.*";
458
+ this.renderer.requestRender();
459
+ return;
460
+ }
461
+
462
+ const d = this._metricsData;
463
+ const m = d.metrics;
464
+ const statusIcon = d.status === "running" ? "\u25CF" : d.status === "error" ? "x" : "\u25CB";
465
+ const modeLabel = d.dryRun ? "paper" : "LIVE";
466
+
467
+ // Uptime
468
+ const uptimeMs = Date.now() - d.startedAt;
469
+ const hours = Math.floor(uptimeMs / 3600000);
470
+ const mins = Math.floor((uptimeMs % 3600000) / 60000);
471
+ const uptime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
472
+
473
+ // Sparkline from pnl history
474
+ const spark = sparkline(d.pnlHistory, 30);
475
+
476
+ // Format helpers
477
+ const pnl = (v: number) => (v >= 0 ? `+$${v.toFixed(2)}` : `-$${Math.abs(v).toFixed(2)}`);
478
+ const pct = (v: number) => `${v.toFixed(1)}%`;
479
+
480
+ const lines: string[] = [];
481
+ lines.push(`**${d.name}** ${statusIcon} ${d.status} | ${modeLabel} | ${uptime}`);
482
+ lines.push("---");
483
+ lines.push("");
484
+
485
+ // P&L section
486
+ lines.push("```");
487
+ lines.push(` Total P&L ${pad(pnl(m.total_pnl), 12)} Realized ${pad(pnl(m.realized_pnl), 12)}`);
488
+ lines.push(` Unrealized ${pad(pnl(m.unrealized_pnl), 12)} Exposure ${pad("$" + m.total_exposure.toFixed(2), 12)}`);
489
+ lines.push("");
490
+ lines.push(` Sharpe ${pad(m.sharpe_ratio.toFixed(2), 12)} Win Rate ${pad(pct(m.win_rate * 100), 12)}`);
491
+ lines.push(` Max DD ${pad(pct(m.max_drawdown_pct), 12)} Trades ${pad(String(m.total_trades), 12)}`);
492
+ lines.push(` Profit F ${pad(m.profit_factor.toFixed(2), 12)} Avg Return ${pad(pnl(m.avg_return_per_trade), 12)}`);
493
+ lines.push("```");
494
+ lines.push("");
495
+
496
+ // Equity sparkline
497
+ if (spark) {
498
+ lines.push("**Equity**");
499
+ lines.push("```");
500
+ lines.push(" " + spark);
501
+ lines.push("```");
502
+ lines.push("");
503
+ }
504
+
505
+ // Positions
506
+ if (d.positions.length > 0) {
507
+ lines.push(`**Positions** (${d.positions.length})`);
508
+ lines.push("```");
509
+ for (const p of d.positions.slice(0, 8)) {
510
+ const side = p.side === "BUY" ? "BUY " : "SELL";
511
+ const slug = p.slug.length > 24 ? p.slug.slice(0, 24) + "..." : p.slug;
512
+ lines.push(` ${side} ${pad(slug, 28)} ${pad(String(p.size), 5)} @ ${p.avg_entry_price.toFixed(2)} ${pnl(p.unrealized_pnl)}`);
513
+ }
514
+ if (d.positions.length > 8) lines.push(` ... and ${d.positions.length - 8} more`);
515
+ lines.push("```");
516
+ lines.push("");
326
517
  }
518
+
519
+ // Recent orders
520
+ if (d.orders.length > 0) {
521
+ lines.push(`**Recent Orders** (${d.orders.length})`);
522
+ lines.push("```");
523
+ for (const o of d.orders.slice(0, 5)) {
524
+ const time = new Date(o.created_at).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
525
+ const side = o.side === "BUY" ? "BUY " : "SELL";
526
+ const slug = o.slug.length > 20 ? o.slug.slice(0, 20) + "..." : o.slug;
527
+ lines.push(` ${time} ${side} ${pad(String(o.size), 4)} @ ${o.price.toFixed(2)} ${pad(o.status, 10)} ${slug}`);
528
+ }
529
+ lines.push("```");
530
+ }
531
+
532
+ this.dashMd.content = lines.join("\n");
327
533
  this.renderer.requestRender();
328
534
  }
329
535
  }
@@ -47,6 +47,9 @@ export class Footer {
47
47
  this.streamingText.content = `${BRAILLE[this.spinnerFrame]} generating`;
48
48
  this.streamingText.fg = COLORS.accent;
49
49
  this.renderer.requestRender();
50
+ } else if (this.streamingText.content !== "") {
51
+ this.streamingText.content = "";
52
+ this.renderer.requestRender();
50
53
  }
51
54
  }, 60);
52
55
  }
@@ -12,6 +12,8 @@ export interface HorizonSettings {
12
12
  autoCompact: boolean;
13
13
  compactThreshold: number;
14
14
  showToolCalls: boolean;
15
+ showWidgets: boolean;
16
+ widgetsInTab: boolean;
15
17
  soundEnabled: boolean;
16
18
  theme: ThemeName;
17
19
  payAsYouGo: boolean;
@@ -25,6 +27,8 @@ const DEFAULT_SETTINGS: HorizonSettings = {
25
27
  autoCompact: true,
26
28
  compactThreshold: 80,
27
29
  showToolCalls: true,
30
+ showWidgets: true,
31
+ widgetsInTab: false,
28
32
  soundEnabled: false,
29
33
  theme: "dark",
30
34
  payAsYouGo: false,
@@ -72,6 +76,16 @@ const SETTINGS_DEFS: SettingDef[] = [
72
76
  description: "Display tool call indicators in chat",
73
77
  type: "toggle",
74
78
  },
79
+ {
80
+ key: "showWidgets", label: "Show Widgets",
81
+ description: "Render tool results as visual widgets (off = text only, LLM still gets data)",
82
+ type: "toggle",
83
+ },
84
+ {
85
+ key: "widgetsInTab", label: "Widgets in Tab",
86
+ description: "Render widgets in a side tab instead of inline in chat",
87
+ type: "toggle",
88
+ },
75
89
  {
76
90
  key: "soundEnabled", label: "Sound",
77
91
  description: "Play terminal bell when generation completes",
@@ -0,0 +1,154 @@
1
+ // Exchange credential profiles — defines what keys each exchange needs
2
+ // Keys are stored encrypted via /env system
3
+
4
+ import { listEncryptedEnvNames, getDecryptedEnv, setEncryptedEnv, removeEncryptedEnv } from "./config.ts";
5
+
6
+ export interface ExchangeProfile {
7
+ id: string;
8
+ name: string;
9
+ category: "prediction" | "stock" | "crypto";
10
+ description: string;
11
+ keys: { env: string; label: string; hint: string; required: boolean }[];
12
+ testUrl?: string;
13
+ }
14
+
15
+ export const EXCHANGE_PROFILES: ExchangeProfile[] = [
16
+ {
17
+ id: "polymarket",
18
+ name: "Polymarket",
19
+ category: "prediction",
20
+ description: "Prediction market on Polygon. Requires a funded wallet private key.",
21
+ keys: [
22
+ { env: "POLYMARKET_PRIVATE_KEY", label: "Private Key", hint: "Ethereum private key (0x...)", required: true },
23
+ { env: "CLOB_API_KEY", label: "CLOB API Key", hint: "From Polymarket developer portal (optional)", required: false },
24
+ { env: "CLOB_SECRET", label: "CLOB Secret", hint: "CLOB API secret", required: false },
25
+ { env: "CLOB_PASSPHRASE", label: "CLOB Passphrase", hint: "CLOB API passphrase", required: false },
26
+ ],
27
+ },
28
+ {
29
+ id: "kalshi",
30
+ name: "Kalshi",
31
+ category: "prediction",
32
+ description: "Regulated US prediction market. API key from kalshi.com/settings.",
33
+ keys: [
34
+ { env: "KALSHI_API_KEY", label: "API Key", hint: "From Kalshi account settings", required: true },
35
+ { env: "KALSHI_API_SECRET", label: "API Secret", hint: "Secret paired with API key", required: false },
36
+ ],
37
+ },
38
+ {
39
+ id: "limitless",
40
+ name: "Limitless",
41
+ category: "prediction",
42
+ description: "Emerging prediction market platform.",
43
+ keys: [
44
+ { env: "LIMITLESS_API_KEY", label: "API Key", hint: "From Limitless developer portal", required: true },
45
+ ],
46
+ },
47
+ {
48
+ id: "alpaca",
49
+ name: "Alpaca",
50
+ category: "stock",
51
+ description: "Commission-free stock/ETF trading. Paper + live. alpaca.markets",
52
+ keys: [
53
+ { env: "ALPACA_API_KEY", label: "API Key", hint: "From Alpaca dashboard (paper or live)", required: true },
54
+ { env: "ALPACA_SECRET_KEY", label: "Secret Key", hint: "Secret paired with API key", required: true },
55
+ { env: "ALPACA_PAPER", label: "Paper Mode", hint: "Set to 'true' for paper trading (default: true)", required: false },
56
+ ],
57
+ },
58
+ {
59
+ id: "robinhood",
60
+ name: "Robinhood",
61
+ category: "stock",
62
+ description: "Stock/options/crypto trading. Uses OAuth token.",
63
+ keys: [
64
+ { env: "ROBINHOOD_TOKEN", label: "Access Token", hint: "OAuth token from Robinhood API", required: true },
65
+ ],
66
+ },
67
+ {
68
+ id: "polygon",
69
+ name: "Polygon.io",
70
+ category: "stock",
71
+ description: "Real-time & historical market data for stocks, options, crypto. polygon.io",
72
+ keys: [
73
+ { env: "POLYGON_API_KEY", label: "API Key", hint: "From polygon.io dashboard (free tier available)", required: true },
74
+ ],
75
+ },
76
+ {
77
+ id: "fmp",
78
+ name: "Financial Modeling Prep",
79
+ category: "stock",
80
+ description: "Fundamental data, financials, earnings. financialmodelingprep.com",
81
+ keys: [
82
+ { env: "FMP_API_KEY", label: "API Key", hint: "From FMP dashboard", required: true },
83
+ ],
84
+ },
85
+ ];
86
+
87
+ export type ConnectionStatus = "connected" | "partial" | "disconnected";
88
+
89
+ export interface ExchangeStatus {
90
+ profile: ExchangeProfile;
91
+ status: ConnectionStatus;
92
+ keysSet: string[];
93
+ keysMissing: string[];
94
+ }
95
+
96
+ /** Check which exchanges have credentials configured */
97
+ export function getExchangeStatuses(): ExchangeStatus[] {
98
+ const envNames = new Set(listEncryptedEnvNames());
99
+ // Also check process.env for keys set externally
100
+ const allKeys = new Set([...envNames, ...Object.keys(process.env).filter(k =>
101
+ EXCHANGE_PROFILES.some(p => p.keys.some(key => key.env === k && process.env[k]))
102
+ )]);
103
+
104
+ return EXCHANGE_PROFILES.map(profile => {
105
+ const keysSet = profile.keys.filter(k => allKeys.has(k.env)).map(k => k.env);
106
+ const keysMissing = profile.keys.filter(k => k.required && !allKeys.has(k.env)).map(k => k.env);
107
+ const requiredKeys = profile.keys.filter(k => k.required);
108
+ const requiredSet = requiredKeys.filter(k => allKeys.has(k.env));
109
+
110
+ let status: ConnectionStatus;
111
+ if (requiredSet.length === requiredKeys.length) {
112
+ status = "connected";
113
+ } else if (keysSet.length > 0) {
114
+ status = "partial";
115
+ } else {
116
+ status = "disconnected";
117
+ }
118
+
119
+ return { profile, status, keysSet, keysMissing };
120
+ });
121
+ }
122
+
123
+ /** Get status for a specific exchange */
124
+ export function getExchangeStatus(exchangeId: string): ExchangeStatus | null {
125
+ return getExchangeStatuses().find(s => s.profile.id === exchangeId) ?? null;
126
+ }
127
+
128
+ /** Set a credential for an exchange */
129
+ export function setExchangeKey(envName: string, value: string): boolean {
130
+ try {
131
+ setEncryptedEnv(envName, value);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** Remove a credential */
139
+ export function removeExchangeKey(envName: string): boolean {
140
+ try {
141
+ removeEncryptedEnv(envName);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** Get a decrypted credential (for passing to subprocesses) */
149
+ export function getExchangeKey(envName: string): string | null {
150
+ // Check encrypted env first, then process.env
151
+ const decrypted = getDecryptedEnv(envName);
152
+ if (decrypted) return decrypted;
153
+ return process.env[envName] ?? null;
154
+ }
@@ -50,7 +50,7 @@ export async function loadSessions(): Promise<void> {
50
50
  mode: (dbs.mode as any) ?? "research",
51
51
  isStreaming: false,
52
52
  streamingMsgId: null,
53
- strategyDraft: dbs.strategy_draft ?? null,
53
+ strategyDraft: dbs.strategy_draft ? { ...dbs.strategy_draft, validationWarnings: dbs.strategy_draft.validationWarnings ?? [] } : null,
54
54
  });
55
55
 
56
56
  // Mark all loaded messages as synced