@reefclaw/connect 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/assets/bridge/gateway/gateway-config.d.ts +16 -5
  2. package/assets/bridge/gateway/gateway-config.js +68 -12
  3. package/assets/bridge/gateway/poller.js +18 -8
  4. package/assets/bridge/providers/emergency-commands.d.ts +9 -1
  5. package/assets/bridge/providers/emergency-commands.js +38 -1
  6. package/assets/bridge/providers/gateway.d.ts +23 -1
  7. package/assets/bridge/providers/gateway.js +79 -17
  8. package/assets/bridge/providers/onboarding-commands.d.ts +8 -0
  9. package/assets/bridge/providers/onboarding-commands.js +4 -4
  10. package/assets/plugin/ccxt/binance-public.d.ts +17 -5
  11. package/assets/plugin/ccxt/binance-public.js +31 -3
  12. package/assets/plugin/config/operator-provenance.d.ts +6 -0
  13. package/assets/plugin/config/operator-provenance.js +50 -0
  14. package/assets/plugin/config/plugin-config-io.d.ts +15 -1
  15. package/assets/plugin/config/plugin-config-io.js +24 -0
  16. package/assets/plugin/index.js +216 -173
  17. package/assets/plugin/ingest/event-loop-monitor.d.ts +11 -0
  18. package/assets/plugin/ingest/event-loop-monitor.js +113 -0
  19. package/assets/plugin/ingest/position-auto-capture.d.ts +5 -0
  20. package/assets/plugin/ingest/position-auto-capture.js +14 -5
  21. package/assets/plugin/ingest/readiness-reporter.d.ts +17 -6
  22. package/assets/plugin/ingest/readiness-reporter.js +88 -9
  23. package/assets/plugin/ingest/skill-version-reader.d.ts +16 -0
  24. package/assets/plugin/ingest/skill-version-reader.js +64 -0
  25. package/assets/plugin/live/approval-lifecycle.d.ts +30 -0
  26. package/assets/plugin/live/approval-lifecycle.js +80 -0
  27. package/assets/plugin/live/bracket-types.d.ts +9 -0
  28. package/assets/plugin/live/live-adapter.d.ts +0 -1
  29. package/assets/plugin/onboarding/runtime.d.ts +34 -1
  30. package/assets/plugin/onboarding/runtime.js +56 -5
  31. package/assets/plugin/openclaw.plugin.json +1 -1
  32. package/assets/plugin/simulator/exchange-simulator.d.ts +45 -2
  33. package/assets/plugin/simulator/exchange-simulator.js +96 -4
  34. package/assets/plugin/simulator/types.d.ts +17 -0
  35. package/assets/plugin/tools/attach-brackets.js +50 -1
  36. package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  37. package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  38. package/assets/plugin/venues/hyperliquid/hl-brackets.d.ts +10 -0
  39. package/assets/plugin/venues/hyperliquid/hl-brackets.js +45 -13
  40. package/assets/plugin/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  41. package/assets/plugin/venues/hyperliquid/hl-fill-ingest.js +69 -0
  42. package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +32 -0
  43. package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +112 -7
  44. package/assets/plugin/venues/hyperliquid/hl-public.d.ts +12 -5
  45. package/assets/plugin/venues/hyperliquid/hl-public.js +24 -3
  46. package/assets/plugin/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  47. package/assets/plugin/venues/hyperliquid/hl-user-stream.js +4 -1
  48. package/assets/shared/index.d.ts +3 -3
  49. package/assets/shared/index.js +2 -2
  50. package/assets/shared/readiness.d.ts +38 -2
  51. package/assets/shared/readiness.js +40 -8
  52. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { type VenueId } from '@reefclaw/shared';
1
2
  export interface GatewayConfig {
2
3
  /** Full gateway URL, e.g. "http://localhost:18789" */
3
4
  gatewayUrl: string;
@@ -5,10 +6,16 @@ export interface GatewayConfig {
5
6
  gatewayToken: string;
6
7
  /** Trading symbol, e.g. "BTC/USDT" */
7
8
  symbol: string;
8
- /** Intelligence service URL (optional regime classification) */
9
+ /** Intelligence service URL (regime classification + trade-result reporting) */
9
10
  intelligenceUrl?: string;
10
11
  /** Connection token for intelligence service auth */
11
12
  connectionToken?: string;
13
+ /** Trading venue this box is configured for. Decides how a canonical symbol
14
+ * is spelled in the intel DB ('BTC/USDT' → 'BTCUSDT' vs 'BTC/USDC' →
15
+ * 'HL_BTC'), so every skill↔intel symbol crossing depends on it.
16
+ * Optional so a hand-built config (tests, embedders) stays valid; every
17
+ * consumer treats absent as 'binance', the historical behaviour. */
18
+ venue?: VenueId;
12
19
  }
13
20
  /** CLI args specific to gateway provider */
14
21
  export interface GatewayCliArgs {
@@ -20,17 +27,21 @@ export interface GatewayCliArgs {
20
27
  /**
21
28
  * Resolve gateway connection config with priority:
22
29
  * 1. CLI args (--gateway-url, --gateway-token, --symbol)
23
- * 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL)
24
- * 3. OpenClaw config file (~/.openclaw/openclaw.json)
25
- * 4. Defaults (localhost:18789, BTC/USDT)
30
+ * 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
31
+ * REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE)
32
+ * 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
33
+ * 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com)
26
34
  *
27
35
  * Returns null for gatewayToken if it can't be resolved (caller must handle).
36
+ * `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
37
+ * matters.
28
38
  */
29
39
  export declare function resolveGatewayConfig(args: GatewayCliArgs): {
30
40
  gatewayUrl: string;
31
41
  gatewayToken: string | null;
32
42
  symbol: string;
33
- intelligenceUrl: string | null;
43
+ intelligenceUrl: string;
44
+ venue: VenueId;
34
45
  };
35
46
  /**
36
47
  * Validate that the resolved config has all required fields.
@@ -5,11 +5,27 @@ import { readFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import JSON5 from 'json5';
8
+ import { parseVenue, VENUE_DEFAULT_SYMBOL } from '@reefclaw/shared';
8
9
  import { logger } from '../logger.js';
9
10
  const TAG = 'gateway-config';
10
11
  const DEFAULT_GATEWAY_PORT = 18789;
11
12
  const DEFAULT_GATEWAY_HOST = 'localhost';
12
- const DEFAULT_SYMBOL = 'BTC/USDT';
13
+ /**
14
+ * Same default the plugin hardcodes (plugin/src/index.ts) so the two processes
15
+ * agree on the intel service without any operator configuration.
16
+ *
17
+ * ★ Before 2026-07-27 the skill had NO default and NO config-file fallback:
18
+ * `intelligenceUrl` came only from `--intelligence-url` or
19
+ * `REEFCLAW_INTELLIGENCE_URL`, and NOTHING in the repo ever set either one —
20
+ * not the installer, not a systemd unit, not a deploy script. The skill is the
21
+ * ONLY writer of intel's `trade_results` table, so on every default install
22
+ * every closed trade was dropped at debug level inside `reportTradeResult`.
23
+ * Downstream that meant `tradeCount` never reached `minTradesForKelly`, Kelly
24
+ * sizing was pinned at `cappedReason: 'insufficient_history'` forever, and
25
+ * `get_agent_profile` reported tier `novice` / 0 trades no matter how much the
26
+ * agent traded. Reads had a default; the write did not.
27
+ */
28
+ const DEFAULT_INTELLIGENCE_URL = 'https://intel.reefclaw.com';
13
29
  // ---- Read OpenClaw config file ----
14
30
  function getConfigPath() {
15
31
  return process.env.OPENCLAW_CONFIG_PATH
@@ -28,18 +44,48 @@ function readOpenClawJson() {
28
44
  return null;
29
45
  }
30
46
  }
47
+ // ---- Read the plugin's own config file ----
48
+ function getPluginConfigPath() {
49
+ return process.env.REEFCLAW_PLUGIN_CONFIG_PATH
50
+ || join(homedir(), '.reefclaw', 'plugin-config.json');
51
+ }
52
+ /**
53
+ * Best-effort read of ~/.reefclaw/plugin-config.json — the file that already
54
+ * carries `intelligenceUrl` and `exchange.venue` for the plugin. Reading the
55
+ * same file here makes the two processes agree by construction rather than by
56
+ * convention (the skill previously had no way at all to learn either value).
57
+ * Fail-soft: null on a missing/unreadable/malformed file, which is the common
58
+ * case on a chat-install box that never writes this file.
59
+ */
60
+ function readPluginConfig() {
61
+ const path = getPluginConfigPath();
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
64
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
65
+ return null;
66
+ return parsed;
67
+ }
68
+ catch {
69
+ logger.debug(TAG, `Could not read plugin config from ${path}`);
70
+ return null;
71
+ }
72
+ }
31
73
  // ---- Resolve gateway config ----
32
74
  /**
33
75
  * Resolve gateway connection config with priority:
34
76
  * 1. CLI args (--gateway-url, --gateway-token, --symbol)
35
- * 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL)
36
- * 3. OpenClaw config file (~/.openclaw/openclaw.json)
37
- * 4. Defaults (localhost:18789, BTC/USDT)
77
+ * 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
78
+ * REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE)
79
+ * 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
80
+ * 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com)
38
81
  *
39
82
  * Returns null for gatewayToken if it can't be resolved (caller must handle).
83
+ * `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
84
+ * matters.
40
85
  */
41
86
  export function resolveGatewayConfig(args) {
42
87
  const ocConfig = readOpenClawJson();
88
+ const pluginConfig = readPluginConfig();
43
89
  // --- Gateway URL ---
44
90
  // CLI > env > construct from openclaw.json port > default
45
91
  const port = ocConfig?.gateway?.port ?? DEFAULT_GATEWAY_PORT;
@@ -56,24 +102,34 @@ export function resolveGatewayConfig(args) {
56
102
  if (fileToken && !args.gatewayToken && !process.env.OPENCLAW_GATEWAY_TOKEN) {
57
103
  logger.info(TAG, 'Using gateway token from OpenClaw config');
58
104
  }
105
+ // --- Venue ---
106
+ // env > plugin-config.json exchange.venue > binance. Resolved BEFORE the
107
+ // symbol because it decides both the symbol default and the intel spelling.
108
+ // parseVenue surfaces a mis-spelled value instead of silently coercing it.
109
+ const { venue, unrecognized } = parseVenue(process.env.REEFCLAW_VENUE ?? pluginConfig?.exchange?.venue);
110
+ if (unrecognized) {
111
+ logger.warn(TAG, `Unrecognized venue '${unrecognized}' — falling back to binance`);
112
+ }
59
113
  // --- Symbol ---
60
- // CLI > env > default
114
+ // CLI > env > the VENUE's default. A venue-blind 'BTC/USDT' default on a
115
+ // hyperliquid box is a symbol that venue rejects everywhere it's consumed
116
+ // (the issue-#174 class of bug).
61
117
  const symbol = args.symbol
62
118
  || process.env.REEFCLAW_SYMBOL
63
- || DEFAULT_SYMBOL;
119
+ || VENUE_DEFAULT_SYMBOL[venue];
64
120
  if (!gatewayToken) {
65
121
  logger.warn(TAG, 'Gateway token not found in CLI args, env vars, or OpenClaw config');
66
122
  }
67
123
  // --- Intelligence URL ---
68
- // CLI > env > default
124
+ // CLI > env > plugin-config.json > default. A default is mandatory: this URL
125
+ // gates the ONLY writer of intel's trade_results (see the constant).
69
126
  const intelligenceUrl = args.intelligenceUrl
70
127
  || process.env.REEFCLAW_INTELLIGENCE_URL
71
- || null;
72
- if (intelligenceUrl) {
73
- logger.info(TAG, `Intelligence service: ${intelligenceUrl}`);
74
- }
128
+ || pluginConfig?.intelligenceUrl
129
+ || DEFAULT_INTELLIGENCE_URL;
130
+ logger.info(TAG, `Intelligence service: ${intelligenceUrl} (venue=${venue})`);
75
131
  logger.debug(TAG, `Resolved: url=${gatewayUrl}, token=${gatewayToken ? '***' : '(none)'}, symbol=${symbol}`);
76
- return { gatewayUrl, gatewayToken, symbol, intelligenceUrl };
132
+ return { gatewayUrl, gatewayToken, symbol, intelligenceUrl, venue };
77
133
  }
78
134
  /**
79
135
  * Validate that the resolved config has all required fields.
@@ -356,8 +356,14 @@ export class Poller {
356
356
  const tool = this.toolMap.fetch_positions;
357
357
  // Fetch ALL positions (no symbol filter) so multi-symbol trades are visible
358
358
  const result = await this.http.invoke(tool, {});
359
- const positions = Array.isArray(result.data) ? result.data : [];
360
- this.callbacks.onPositions(positions);
359
+ if (!Array.isArray(result.data)) {
360
+ // null ≠ empty: this cache is the emergency-Flatten fallback. Collapsing
361
+ // a garbage response to [] would let a later failed fresh read report
362
+ // "No positions to close" off poisoned data (audit 2026-07-26 F6).
363
+ logger.warn(TAG, `fetch_positions returned non-array (${typeof result.data}) — keeping last known positions`);
364
+ return;
365
+ }
366
+ this.callbacks.onPositions(result.data);
361
367
  }
362
368
  async pollBalance() {
363
369
  const tool = this.toolMap.fetch_balance;
@@ -370,13 +376,17 @@ export class Poller {
370
376
  // Fetch ALL open orders (no symbol filter) — this cache is the fallback
371
377
  // for the portfolio-wide Kill + reconcile snapshot (see interval comment).
372
378
  const result = await this.http.invoke(tool, {});
379
+ if (!Array.isArray(result.data)) {
380
+ // null ≠ empty: same reasoning as pollPositions — a garbage response
381
+ // must not overwrite the Kill fallback cache with a confirmed-empty [].
382
+ logger.warn(TAG, `fetch_open_orders returned non-array (${typeof result.data}) — keeping last known orders`);
383
+ return;
384
+ }
373
385
  const orders = [];
374
- if (Array.isArray(result.data)) {
375
- for (const ccxt of result.data) {
376
- const mapped = mapCcxtOrder(ccxt);
377
- if (mapped)
378
- orders.push(mapped);
379
- }
386
+ for (const ccxt of result.data) {
387
+ const mapped = mapCcxtOrder(ccxt);
388
+ if (mapped)
389
+ orders.push(mapped);
380
390
  }
381
391
  this.callbacks.onOpenOrders(orders);
382
392
  }
@@ -29,8 +29,16 @@ export interface EmergencyContext {
29
29
  *
30
30
  * Caller must set agentMode = 'STOPPED' after this returns, and should pass a
31
31
  * FRESH all-symbols order snapshot (see the gateway wrapper).
32
+ *
33
+ * ★ null ≠ empty (audit 2026-07-26 F6): `freshOrders === null` means the fresh
34
+ * fetch FAILED — order state is unknown. A stale (≤60s) cache that happens to
35
+ * be empty is NOT proof of zero working orders, and reporting executed:true
36
+ * from it is exactly the false-green the 2026-06-19 fix exists to prevent. On
37
+ * a failed fresh read Kill still halts the agent and best-effort cancels the
38
+ * cached-known working set, but ALWAYS reports executed:false with the
39
+ * unverified-coverage reason.
32
40
  */
33
- export declare function executeKill(ctx: EmergencyContext, openOrders: OrderData[]): Promise<EmergencyResult>;
41
+ export declare function executeKill(ctx: EmergencyContext, freshOrders: OrderData[] | null, cachedOrders?: OrderData[]): Promise<EmergencyResult>;
34
42
  /**
35
43
  * Flatten: close all open positions at market.
36
44
  * Caller must set agentMode = 'STOPPED' after this returns.
@@ -30,11 +30,21 @@ function rejectionReasons(results, max = 3) {
30
30
  *
31
31
  * Caller must set agentMode = 'STOPPED' after this returns, and should pass a
32
32
  * FRESH all-symbols order snapshot (see the gateway wrapper).
33
+ *
34
+ * ★ null ≠ empty (audit 2026-07-26 F6): `freshOrders === null` means the fresh
35
+ * fetch FAILED — order state is unknown. A stale (≤60s) cache that happens to
36
+ * be empty is NOT proof of zero working orders, and reporting executed:true
37
+ * from it is exactly the false-green the 2026-06-19 fix exists to prevent. On
38
+ * a failed fresh read Kill still halts the agent and best-effort cancels the
39
+ * cached-known working set, but ALWAYS reports executed:false with the
40
+ * unverified-coverage reason.
33
41
  */
34
- export async function executeKill(ctx, openOrders) {
42
+ export async function executeKill(ctx, freshOrders, cachedOrders = []) {
35
43
  if (!ctx.http) {
36
44
  return { action: 'kill', executed: false, timestamp: new Date().toISOString(), details: 'HTTP client not initialized' };
37
45
  }
46
+ const fresh = freshOrders !== null;
47
+ const openOrders = freshOrders ?? cachedOrders;
38
48
  // Protective = a stop/TP bracket (reduceOnly OR closePosition OR stop-type;
39
49
  // see mapCcxtOrder/isProtectiveOrder). Working = everything else (entries /
40
50
  // adds). Preserve protective so an open position is never left naked.
@@ -95,6 +105,15 @@ export async function executeKill(ctx, openOrders) {
95
105
  logger.warn(TAG, details);
96
106
  }
97
107
  }
108
+ if (!fresh) {
109
+ // Coverage is unverifiable: whatever the cached list said, orders placed
110
+ // in the last ≤60s are invisible to it. Never render a green "Killed".
111
+ executed = false;
112
+ details = working.length === 0
113
+ ? `Agent halted, but order state is UNVERIFIED — fresh order fetch failed and the cached snapshot shows no working orders; verify on the exchange${preservedNote}`
114
+ : `${details} — STALE snapshot (fresh order fetch failed); full coverage unverified`;
115
+ logger.error(TAG, details);
116
+ }
98
117
  return { action: 'kill', executed, timestamp: new Date().toISOString(), details };
99
118
  }
100
119
  /**
@@ -110,13 +129,19 @@ export async function executeFlatten(ctx, cachedPositions) {
110
129
  // ctx.symbol here narrowed the result to a single symbol, so positions on
111
130
  // every other symbol were silently skipped and never closed. The cached
112
131
  // fallback (this.positions) is already all-symbols.
132
+ // ★ null ≠ empty (audit 2026-07-26 F6): `fresh` records whether the list is
133
+ // exchange-confirmed. A stale cache that happens to be empty must NEVER
134
+ // produce a green "Flattened" — a position opened in the last ≤60s would be
135
+ // invisible to it and left running behind a STOPPED agent.
113
136
  let positions = cachedPositions;
137
+ let fresh = false;
114
138
  const fetchTool = ctx.toolMap.fetch_positions;
115
139
  if (fetchTool) {
116
140
  try {
117
141
  const result = await ctx.http.invokeEmergency(fetchTool, {});
118
142
  if (Array.isArray(result.data)) {
119
143
  positions = result.data;
144
+ fresh = true;
120
145
  }
121
146
  else {
122
147
  // Mirror gateway.ts fetchFreshPositions: a non-array response must
@@ -132,6 +157,11 @@ export async function executeFlatten(ctx, cachedPositions) {
132
157
  // Filter to non-zero positions
133
158
  const openPositions = positions.filter((p) => (p.contracts ?? 0) !== 0);
134
159
  if (openPositions.length === 0) {
160
+ if (!fresh) {
161
+ const details = 'Position state UNVERIFIED — fresh position fetch failed and the cached snapshot shows none; nothing was closed, verify on the exchange';
162
+ logger.error(TAG, details);
163
+ return { action: 'flatten', executed: false, timestamp: new Date().toISOString(), details };
164
+ }
135
165
  return { action: 'flatten', executed: true, timestamp: new Date().toISOString(), details: 'No positions to close' };
136
166
  }
137
167
  // Close each position
@@ -179,6 +209,13 @@ export async function executeFlatten(ctx, cachedPositions) {
179
209
  logger.error(TAG, details);
180
210
  return { action: 'flatten', executed: false, timestamp: new Date().toISOString(), details };
181
211
  }
212
+ if (!fresh) {
213
+ // Every cached-known position closed, but positions opened in the last
214
+ // ≤60s are invisible to a stale snapshot — coverage is unverifiable.
215
+ const details = `Closed ${succeeded}/${openPositions.length} positions from a STALE snapshot (fresh position fetch failed) — full coverage unverified, verify on the exchange`;
216
+ logger.error(TAG, details);
217
+ return { action: 'flatten', executed: false, timestamp: new Date().toISOString(), details };
218
+ }
182
219
  const details = `Closed ${succeeded}/${openPositions.length} positions`;
183
220
  return { action: 'flatten', executed: true, timestamp: new Date().toISOString(), details };
184
221
  }
@@ -243,6 +243,20 @@ export declare class GatewayProvider implements OpenClawProvider {
243
243
  * the WS store; under weight pressure isStoreTrusted() flickers between
244
244
  * the two, so the raw symbol alternates poll-to-poll. */
245
245
  private canonicalSymbol;
246
+ /** Canonical symbol → the intel DB symbol for this box's venue.
247
+ * binance: 'BTC/USDT' → 'BTCUSDT'; hyperliquid: 'BTC/USDC' → 'HL_BTC'.
248
+ *
249
+ * ★ The old venue-blind `.replace('/','')` produced 'BTCUSDC' on a
250
+ * hyperliquid box — a symbol no intel row has ever carried (intel
251
+ * namespaces every HL row under the 'HL_' prefix). That silently broke the
252
+ * regime/signal/mission/analytics pollers AND wrote every trade result to a
253
+ * symbol Kelly sizing would never read back, so a hyperliquid box stayed at
254
+ * `insufficient_history` even once the reporting path itself worked.
255
+ *
256
+ * FAILS OPEN: an unmappable symbol falls back to the legacy concatenation
257
+ * so intel answers "no data for <echo>" — honest and debuggable — rather
258
+ * than the caller throwing and dropping the report entirely. */
259
+ private toIntelSymbol;
246
260
  private onPollerPositions;
247
261
  /**
248
262
  * Compare previous and current positions from polling.
@@ -279,6 +293,14 @@ export declare class GatewayProvider implements OpenClawProvider {
279
293
  private lastMarketStructure;
280
294
  private fetchFreshPositions;
281
295
  private fetchFreshBalance;
296
+ /** Strict fresh read — null on ANY failure (missing tool, thrown fetch,
297
+ * non-array garbage). Callers whose verdict claims order-state coverage
298
+ * (Kill) MUST distinguish "confirmed list" from "unknown"; collapsing a
299
+ * failed read into the ≤60s cache here was how a stale-empty cache became
300
+ * a green "No open orders to cancel" (audit 2026-07-26 F6). */
301
+ private fetchFreshOpenOrdersOrNull;
302
+ /** Cached-fallback wrapper for DISPLAY surfaces (the reconcile snapshot):
303
+ * stale data beats a blank panel there. Never use for emergency verdicts. */
282
304
  private fetchFreshOpenOrders;
283
305
  private fetchAgentHealth;
284
306
  /**
@@ -387,7 +409,7 @@ export declare class GatewayProvider implements OpenClawProvider {
387
409
  * truthful-unknown beats confidently-wrong.
388
410
  */
389
411
  private seedLastTradeTs;
390
- /** Symbol formatted for intelligence API (BTC/USDT BTCUSDT). */
412
+ /** The configured symbol as the intel DB spells it. */
391
413
  private get intelligenceSymbol();
392
414
  /** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
393
415
  private startIntelligencePoller;
@@ -6,6 +6,7 @@ import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { logger, formatError } from '../logger.js';
8
8
  import { isTradingMode } from '../types.js';
9
+ import { toIntelSymbol } from '@reefclaw/shared';
9
10
  import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
10
11
  import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
11
12
  import { discoverTools } from '../gateway/tool-discovery.js';
@@ -572,10 +573,11 @@ export class GatewayProvider {
572
573
  const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
573
574
  // Pass a FRESH, all-symbols order snapshot so Kill cancels every working
574
575
  // order portfolio-wide and correctly identifies (and preserves) protective
575
- // reduceOnly brackets. fetchFreshOpenOrders falls back to this.openOrders on
576
- // any fetch error, so this never blocks the kill.
577
- const freshOrders = await this.fetchFreshOpenOrders();
578
- const result = await _executeKill(ctx, freshOrders);
576
+ // reduceOnly brackets. On a failed fresh read Kill still runs against the
577
+ // ≤60s cache (never blocked) but reports executed:false a stale cache
578
+ // cannot verify coverage (audit 2026-07-26 F6).
579
+ const freshOrders = await this.fetchFreshOpenOrdersOrNull();
580
+ const result = await _executeKill(ctx, freshOrders, this.openOrders);
579
581
  this.agentMode = 'STOPPED';
580
582
  this.emitAgentState();
581
583
  return result;
@@ -660,16 +662,23 @@ export class GatewayProvider {
660
662
  // Thin wrappers — real logic lives in providers/onboarding-commands.ts
661
663
  // so unit tests can drive pure functions with a mocked HTTP client.
662
664
  async setTradingMode(mode, acknowledged) {
663
- return executeSetTradingMode({ http: this.http, toolMap: this.toolMap }, mode, acknowledged);
665
+ return executeSetTradingMode(
666
+ // operatorToken = the rc_* connection token — the plugin refuses the
667
+ // four state-mutating operator tools without it (audit F12).
668
+ { http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, mode, acknowledged);
664
669
  }
665
670
  async setExchangeCredentials(apiKey, secret, testnet) {
666
- return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
671
+ return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
667
672
  }
668
673
  async testExchangeCredentials(apiKey, secret, testnet) {
669
674
  return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
670
675
  }
671
676
  async clearExchangeCredentials() {
672
- return executeClearExchangeCredentials({ http: this.http, toolMap: this.toolMap });
677
+ return executeClearExchangeCredentials({
678
+ http: this.http,
679
+ toolMap: this.toolMap,
680
+ operatorToken: this.config.connectionToken,
681
+ });
673
682
  }
674
683
  /** Operator-only. Read the current bracket-orders config from the plugin. */
675
684
  async getBracketConfig() {
@@ -677,7 +686,7 @@ export class GatewayProvider {
677
686
  }
678
687
  /** Operator-only. Flip requireStopLoss or requireTakeProfit. */
679
688
  async setBracketRequirement(flag, value) {
680
- return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap }, flag, value);
689
+ return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, flag, value);
681
690
  }
682
691
  async getSnapshot() {
683
692
  logger.info(TAG, 'Reconciliation snapshot requested');
@@ -1195,6 +1204,28 @@ export class GatewayProvider {
1195
1204
  canonicalSymbol(s) {
1196
1205
  return typeof s === 'string' ? s.replace(/:[A-Z]+$/, '') : s;
1197
1206
  }
1207
+ /** Canonical symbol → the intel DB symbol for this box's venue.
1208
+ * binance: 'BTC/USDT' → 'BTCUSDT'; hyperliquid: 'BTC/USDC' → 'HL_BTC'.
1209
+ *
1210
+ * ★ The old venue-blind `.replace('/','')` produced 'BTCUSDC' on a
1211
+ * hyperliquid box — a symbol no intel row has ever carried (intel
1212
+ * namespaces every HL row under the 'HL_' prefix). That silently broke the
1213
+ * regime/signal/mission/analytics pollers AND wrote every trade result to a
1214
+ * symbol Kelly sizing would never read back, so a hyperliquid box stayed at
1215
+ * `insufficient_history` even once the reporting path itself worked.
1216
+ *
1217
+ * FAILS OPEN: an unmappable symbol falls back to the legacy concatenation
1218
+ * so intel answers "no data for <echo>" — honest and debuggable — rather
1219
+ * than the caller throwing and dropping the report entirely. */
1220
+ toIntelSymbol(symbol) {
1221
+ const canonical = this.canonicalSymbol(symbol);
1222
+ try {
1223
+ return toIntelSymbol(this.config.venue ?? 'binance', canonical);
1224
+ }
1225
+ catch {
1226
+ return canonical.replace('/', '');
1227
+ }
1228
+ }
1198
1229
  async onPollerPositions(positions) {
1199
1230
  // Canonicalize symbols at this single position-ingress chokepoint so
1200
1231
  // the ENTIRE skill (position-diff, openTradeEntries keys, snapshots,
@@ -1737,10 +1768,15 @@ export class GatewayProvider {
1737
1768
  return this.balance;
1738
1769
  }
1739
1770
  }
1740
- async fetchFreshOpenOrders() {
1771
+ /** Strict fresh read — null on ANY failure (missing tool, thrown fetch,
1772
+ * non-array garbage). Callers whose verdict claims order-state coverage
1773
+ * (Kill) MUST distinguish "confirmed list" from "unknown"; collapsing a
1774
+ * failed read into the ≤60s cache here was how a stale-empty cache became
1775
+ * a green "No open orders to cancel" (audit 2026-07-26 F6). */
1776
+ async fetchFreshOpenOrdersOrNull() {
1741
1777
  const tool = this.toolMap.fetch_open_orders;
1742
1778
  if (!tool || !this.http)
1743
- return this.openOrders;
1779
+ return null;
1744
1780
  try {
1745
1781
  // Fetch ALL open orders (no symbol filter). Multi-symbol orphan orders
1746
1782
  // submitted while the browser was disconnected must show up in the
@@ -1749,7 +1785,7 @@ export class GatewayProvider {
1749
1785
  const result = await this.http.invoke(tool, {});
1750
1786
  if (!Array.isArray(result.data)) {
1751
1787
  logger.warn(TAG, `fetch_open_orders returned non-array: ${typeof result.data}`);
1752
- return this.openOrders;
1788
+ return null;
1753
1789
  }
1754
1790
  const mapped = result.data
1755
1791
  .map((o) => mapCcxtOrder(o))
@@ -1761,10 +1797,15 @@ export class GatewayProvider {
1761
1797
  return mapped;
1762
1798
  }
1763
1799
  catch (err) {
1764
- logger.warn(TAG, `Fresh open orders fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
1765
- return this.openOrders;
1800
+ logger.warn(TAG, `Fresh open orders fetch failed: ${err instanceof Error ? err.message : String(err)}`);
1801
+ return null;
1766
1802
  }
1767
1803
  }
1804
+ /** Cached-fallback wrapper for DISPLAY surfaces (the reconcile snapshot):
1805
+ * stale data beats a blank panel there. Never use for emergency verdicts. */
1806
+ async fetchFreshOpenOrders() {
1807
+ return (await this.fetchFreshOpenOrdersOrNull()) ?? this.openOrders;
1808
+ }
1768
1809
  async fetchAgentHealth() {
1769
1810
  const ws = this.wsClient;
1770
1811
  if (!ws)
@@ -2309,7 +2350,13 @@ export class GatewayProvider {
2309
2350
  const url = this.config.intelligenceUrl;
2310
2351
  const token = this.config.connectionToken;
2311
2352
  if (!url || !token) {
2312
- logger.debug(TAG, 'Intelligence not configured skipping trade result report');
2353
+ // NEVER debug-level. This is the only writer of intel's `trade_results`,
2354
+ // and those rows are what `minTradesForKelly` counts and what
2355
+ // `get_agent_profile` derives its tier from. Silently skipping here is
2356
+ // indistinguishable from "the agent has never traded" — which is exactly
2357
+ // how it read for months. See DEFAULT_INTELLIGENCE_URL in gateway-config.
2358
+ logger.warn(TAG, `Intelligence not configured (url=${url ? 'set' : 'MISSING'}, token=${token ? 'set' : 'MISSING'}) — DROPPING trade result for ${symbol}. `
2359
+ + 'Kelly sizing and the agent profile are computed from these rows and will stay pinned at their no-history defaults.');
2313
2360
  return;
2314
2361
  }
2315
2362
  const direction = entry.side === 'long' ? 'LONG' : 'SHORT';
@@ -2321,7 +2368,7 @@ export class GatewayProvider {
2321
2368
  const durationSeconds = Math.floor((Date.now() - new Date(entry.entryTime).getTime()) / 1000);
2322
2369
  const tradeResult = {
2323
2370
  missionId: entry.missionId ?? `trade-${Date.now()}`,
2324
- symbol: symbol.replace('/', ''), // BTC/USDT → BTCUSDT for Intelligence API
2371
+ symbol: this.toIntelSymbol(symbol), // venue-aware: BTC/USDT → BTCUSDT, BTC/USDC HL_BTC
2325
2372
  direction,
2326
2373
  strategy: this.lastKnownStrategy.name,
2327
2374
  regime: entry.regime ?? this.currentRegime,
@@ -2406,9 +2453,9 @@ export class GatewayProvider {
2406
2453
  }
2407
2454
  }
2408
2455
  // ---- Intelligence service polling ----
2409
- /** Symbol formatted for intelligence API (BTC/USDT BTCUSDT). */
2456
+ /** The configured symbol as the intel DB spells it. */
2410
2457
  get intelligenceSymbol() {
2411
- return this.config.symbol.replace('/', '');
2458
+ return this.toIntelSymbol(this.config.symbol);
2412
2459
  }
2413
2460
  /** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
2414
2461
  startIntelligencePoller(name, endpoint, intervalMs, onData, intervalRef, delayFirstMs = 0) {
@@ -2420,6 +2467,21 @@ export class GatewayProvider {
2420
2467
  }
2421
2468
  return;
2422
2469
  }
2470
+ // These five read-pollers (regime 60s, signals 10s, missions 10s,
2471
+ // analytics 30s, decision-trace 30s ≈ 22 req/min per box) were dormant for
2472
+ // as long as `intelligenceUrl` had no default. Giving it one turns them on
2473
+ // everywhere, which is the intended behaviour — they feed the dashboard's
2474
+ // regime/signals/missions panels — but it IS new load on an intel API with
2475
+ // a known heap leak. Kill-switch so that load can be shed in one restart
2476
+ // WITHOUT taking the trade_results writer down with it: the writer is what
2477
+ // Kelly sizing and the agent tier are computed from, and it does not go
2478
+ // through this function.
2479
+ if (process.env.RC_SKILL_INTEL_POLL === 'off') {
2480
+ if (name === 'regime') {
2481
+ logger.warn(TAG, 'RC_SKILL_INTEL_POLL=off — intelligence read-polling disabled (trade-result reporting is unaffected)');
2482
+ }
2483
+ return;
2484
+ }
2423
2485
  const symbol = this.intelligenceSymbol;
2424
2486
  const fullUrl = `${url}/api/${endpoint}/${symbol}`;
2425
2487
  logger.info(TAG, `Starting ${name} poller: ${fullUrl}`);
@@ -38,6 +38,14 @@ export interface ClearExchangeCredentialsOutcome {
38
38
  export interface OnboardingContext {
39
39
  http: GatewayHttpClient | null;
40
40
  toolMap: ToolMap;
41
+ /** rc_* connection token, injected as `operator_token` on the four
42
+ * state-MUTATING operator tools (set_trading_mode /
43
+ * set_exchange_credentials / clear_exchange_credentials /
44
+ * set_bracket_requirement). The plugin refuses those calls without it
45
+ * (audit 2026-07-26 F12) — this is the operator-provenance proof that the
46
+ * request came through the dashboard path, which the agent cannot forge
47
+ * conversationally (chat redaction strips rc_* tokens). */
48
+ operatorToken?: string;
41
49
  }
42
50
  export type BracketMode = 'off' | 'observe' | 'enforce';
43
51
  export type BracketRequirementFlag = 'requireStopLoss' | 'requireTakeProfit';
@@ -23,7 +23,7 @@ export async function executeSetTradingMode(ctx, mode, acknowledged) {
23
23
  };
24
24
  }
25
25
  try {
26
- const result = await ctx.http.invoke(tool, { mode, acknowledged });
26
+ const result = await ctx.http.invoke(tool, { mode, acknowledged, operator_token: ctx.operatorToken });
27
27
  return result.data;
28
28
  }
29
29
  catch (err) {
@@ -93,7 +93,7 @@ export async function executeClearExchangeCredentials(ctx) {
93
93
  };
94
94
  }
95
95
  try {
96
- const result = await ctx.http.invoke(tool, { confirm: true });
96
+ const result = await ctx.http.invoke(tool, { confirm: true, operator_token: ctx.operatorToken });
97
97
  return result.data;
98
98
  }
99
99
  catch (err) {
@@ -162,7 +162,7 @@ export async function executeSetBracketRequirement(ctx, flag, value) {
162
162
  };
163
163
  }
164
164
  try {
165
- const result = await ctx.http.invoke(tool, { flag, value });
165
+ const result = await ctx.http.invoke(tool, { flag, value, operator_token: ctx.operatorToken });
166
166
  return result.data;
167
167
  }
168
168
  catch (err) {
@@ -194,7 +194,7 @@ export async function executeSetExchangeCredentials(ctx, apiKey, secret, testnet
194
194
  };
195
195
  }
196
196
  try {
197
- const result = await ctx.http.invoke(tool, { apiKey, secret, testnet });
197
+ const result = await ctx.http.invoke(tool, { apiKey, secret, testnet, operator_token: ctx.operatorToken });
198
198
  return result.data;
199
199
  }
200
200
  catch (err) {
@@ -1,3 +1,4 @@
1
+ import { type VenueReachabilityResult } from '@reefclaw/shared';
1
2
  import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
3
  import type { OrderBookDepth } from '../simulator/types.js';
3
4
  import type { PublicMarketDataApi } from './public-market-data-api.js';
@@ -28,9 +29,20 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
28
29
  * - 'reachable' clean response (driftMs = serverTime − localTime)
29
30
  * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
30
31
  * - 'unknown' the ban/weight gate paused us — NOT a host problem
31
- * - 'unreachable' network / DNS / timeout / other error */
32
- probeReachability(): Promise<{
33
- outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
34
- driftMs: number | null;
35
- }>;
32
+ * - 'stalled' THIS process was starved we learned nothing (issue #265)
33
+ * - 'unreachable' network / DNS / timeout / other error
34
+ *
35
+ * ★ Self-stall detection: an error is evidence about BINANCE only if it
36
+ * arrived on schedule. When the host starves the process the loop stops
37
+ * running, ccxt's own timeout lands minutes late, and the old code blamed the
38
+ * network — producing a confident "you are geo-blocked / check your firewall"
39
+ * banner while the agent was in fact trading normally. Past
40
+ * REACHABILITY_STALL_FACTOR× the request budget we report `stalled` and the
41
+ * reporter renders it `unknown`. A 451 still wins: the server ANSWERED, so
42
+ * that classification stands however late we noticed it. */
43
+ probeReachability(): Promise<VenueReachabilityResult>;
44
+ /** The request budget the stall yardstick is measured against. ccxt owns the
45
+ * actual timeout, so read it from the instance rather than hardcoding a
46
+ * second copy that could silently drift from it. */
47
+ private probeTimeoutMs;
36
48
  }