@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

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 (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
@@ -6,12 +6,14 @@ 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';
12
13
  import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, extractPlannedLevels, } from '../gateway/event-parser.js';
13
14
  import { Poller } from '../gateway/poller.js';
14
- import { ensureHeartbeatCron } from '../gateway/heartbeat-cron.js';
15
+ import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
16
+ import { parseIdentityName } from '../utils/identity-name.js';
15
17
  import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
16
18
  import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
17
19
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
@@ -130,6 +132,8 @@ function saveSessionStartNav(nav) {
130
132
  }
131
133
  // ---- Agent state emission interval ----
132
134
  const AGENT_STATE_INTERVAL_MS = 5_000;
135
+ /** How often IDENTITY.md is re-read for the agent's display name (local file read). */
136
+ const AGENT_NAME_TTL_MS = 60_000;
133
137
  // ---- GatewayProvider ----
134
138
  export class GatewayProvider {
135
139
  config;
@@ -154,12 +158,21 @@ export class GatewayProvider {
154
158
  agentIdentity = {};
155
159
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
156
160
  loggedAgentIdentityProbe = false;
161
+ /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
162
+ agentNameReadAtMs = 0;
157
163
  /** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
158
164
  heartbeatCronEnsureStarted = false;
159
165
  /** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
160
166
  heartbeatSeconds;
161
167
  heartbeatReadAtMs = 0;
162
168
  loggedHeartbeat = false;
169
+ /** Heartbeat-job health read from `cron.list` (RPC — the filesystem jobs.json
170
+ * is absent on 2026.7.x). Surfaced so the dashboard shows a failing/stale
171
+ * beat instead of a silent gap (a beat failed 10× over ~2 days undetected,
172
+ * 2026-07-24). undefined until first resolved; refreshed lazily (120s). */
173
+ heartbeatHealth;
174
+ heartbeatHealthReadAtMs = 0;
175
+ heartbeatHealthRefreshing = false;
163
176
  lastTicker = null;
164
177
  positions = [];
165
178
  balance = { currency: 'USDT', total: 0, available: 0, locked: 0 };
@@ -230,6 +243,13 @@ export class GatewayProvider {
230
243
  // ---- Trade tracking ----
231
244
  /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
232
245
  lastTradeTs = null;
246
+ /** Epoch ms of the most recent LIVE fill observed this process (issue #248).
247
+ * Deliberately separate from `lastTradeTs`: that one is a display string and
248
+ * is also seeded from the historical trades ledger at startup, which must
249
+ * NOT count here — this field exists solely to prove whether the current
250
+ * `positions` snapshot predates a fill, and a historical seed would make an
251
+ * untorn snapshot look torn forever. 0 = no fill seen yet this process. */
252
+ lastFillAt = 0;
233
253
  /** Tracks open position entries for computing trade results on close.
234
254
  * Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
235
255
  openTradeEntries = new Map();
@@ -553,10 +573,11 @@ export class GatewayProvider {
553
573
  const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
554
574
  // Pass a FRESH, all-symbols order snapshot so Kill cancels every working
555
575
  // order portfolio-wide and correctly identifies (and preserves) protective
556
- // reduceOnly brackets. fetchFreshOpenOrders falls back to this.openOrders on
557
- // any fetch error, so this never blocks the kill.
558
- const freshOrders = await this.fetchFreshOpenOrders();
559
- 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);
560
581
  this.agentMode = 'STOPPED';
561
582
  this.emitAgentState();
562
583
  return result;
@@ -641,16 +662,23 @@ export class GatewayProvider {
641
662
  // Thin wrappers — real logic lives in providers/onboarding-commands.ts
642
663
  // so unit tests can drive pure functions with a mocked HTTP client.
643
664
  async setTradingMode(mode, acknowledged) {
644
- 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);
645
669
  }
646
670
  async setExchangeCredentials(apiKey, secret, testnet) {
647
- 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);
648
672
  }
649
673
  async testExchangeCredentials(apiKey, secret, testnet) {
650
674
  return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
651
675
  }
652
676
  async clearExchangeCredentials() {
653
- 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
+ });
654
682
  }
655
683
  /** Operator-only. Read the current bracket-orders config from the plugin. */
656
684
  async getBracketConfig() {
@@ -658,7 +686,7 @@ export class GatewayProvider {
658
686
  }
659
687
  /** Operator-only. Flip requireStopLoss or requireTakeProfit. */
660
688
  async setBracketRequirement(flag, value) {
661
- 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);
662
690
  }
663
691
  async getSnapshot() {
664
692
  logger.info(TAG, 'Reconciliation snapshot requested');
@@ -1152,6 +1180,10 @@ export class GatewayProvider {
1152
1180
  // Track last trade timestamp for agent state
1153
1181
  if (data.status === 'FILLED') {
1154
1182
  this.lastTradeTs = data.timestamp ?? new Date().toISOString();
1183
+ // A fill debits the paper wallet immediately; `positions` catches up
1184
+ // on its own poll. Record when, so the RED-zone guard can tell a torn
1185
+ // wallet/positions snapshot from a real drawdown (issue #248).
1186
+ this.lastFillAt = Date.now();
1155
1187
  }
1156
1188
  }
1157
1189
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1172,6 +1204,28 @@ export class GatewayProvider {
1172
1204
  canonicalSymbol(s) {
1173
1205
  return typeof s === 'string' ? s.replace(/:[A-Z]+$/, '') : s;
1174
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
+ }
1175
1229
  async onPollerPositions(positions) {
1176
1230
  // Canonicalize symbols at this single position-ingress chokepoint so
1177
1231
  // the ENTIRE skill (position-diff, openTradeEntries keys, snapshots,
@@ -1423,6 +1477,10 @@ export class GatewayProvider {
1423
1477
  };
1424
1478
  logger.info(TAG, `Synthetic fill: ${side} ${quantity} ${pos.symbol} @ ${fillPrice} (from position poll)`);
1425
1479
  this.lastTradeTs = now;
1480
+ // Same reasoning as the orderUpdate path (issue #248) — this one is derived
1481
+ // FROM a positions poll, so the snapshot already covers it and the guard
1482
+ // clears on the next refresh rather than latching.
1483
+ this.lastFillAt = Date.now();
1426
1484
  this.fire('orderUpdate', payload);
1427
1485
  // Track open entries for trade result reporting
1428
1486
  const tradeSide = pos.side === 'short' ? 'short' : 'long';
@@ -1710,10 +1768,15 @@ export class GatewayProvider {
1710
1768
  return this.balance;
1711
1769
  }
1712
1770
  }
1713
- 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() {
1714
1777
  const tool = this.toolMap.fetch_open_orders;
1715
1778
  if (!tool || !this.http)
1716
- return this.openOrders;
1779
+ return null;
1717
1780
  try {
1718
1781
  // Fetch ALL open orders (no symbol filter). Multi-symbol orphan orders
1719
1782
  // submitted while the browser was disconnected must show up in the
@@ -1722,7 +1785,7 @@ export class GatewayProvider {
1722
1785
  const result = await this.http.invoke(tool, {});
1723
1786
  if (!Array.isArray(result.data)) {
1724
1787
  logger.warn(TAG, `fetch_open_orders returned non-array: ${typeof result.data}`);
1725
- return this.openOrders;
1788
+ return null;
1726
1789
  }
1727
1790
  const mapped = result.data
1728
1791
  .map((o) => mapCcxtOrder(o))
@@ -1734,10 +1797,15 @@ export class GatewayProvider {
1734
1797
  return mapped;
1735
1798
  }
1736
1799
  catch (err) {
1737
- logger.warn(TAG, `Fresh open orders fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
1738
- return this.openOrders;
1800
+ logger.warn(TAG, `Fresh open orders fetch failed: ${err instanceof Error ? err.message : String(err)}`);
1801
+ return null;
1739
1802
  }
1740
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
+ }
1741
1809
  async fetchAgentHealth() {
1742
1810
  const ws = this.wsClient;
1743
1811
  if (!ws)
@@ -1799,6 +1867,70 @@ export class GatewayProvider {
1799
1867
  this.emitAgentState();
1800
1868
  }
1801
1869
  }
1870
+ /**
1871
+ * Read the heartbeat cron job's health from `cron.list` (RPC — the
1872
+ * filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
1873
+ * Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
1874
+ * job and caches it. Best-effort, fail-open (a version/scope that hides
1875
+ * cron.list simply leaves health undefined → the dashboard shows nothing new,
1876
+ * byte-identical to before). Re-emits agent_state when the value changes so a
1877
+ * newly-failing beat surfaces without waiting for the next tick.
1878
+ *
1879
+ * Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
1880
+ * (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
1881
+ * `consecutiveErrors` was sitting right here in the cron state the whole time.
1882
+ */
1883
+ async refreshHeartbeatHealth() {
1884
+ const ws = this.wsClient;
1885
+ if (!ws)
1886
+ return;
1887
+ this.heartbeatHealthRefreshing = true;
1888
+ try {
1889
+ const resp = await ws.sendRpc('cron.list', { includeDisabled: true });
1890
+ const jobs = resp?.jobs;
1891
+ if (!Array.isArray(jobs))
1892
+ return; // unknown shape — leave last-known
1893
+ const hb = jobs.find((j) => isHeartbeatLikeName(j?.name));
1894
+ const state = hb?.state;
1895
+ if (!state || typeof state !== 'object')
1896
+ return;
1897
+ const s = state;
1898
+ const next = {
1899
+ consecutiveErrors: typeof s.consecutiveErrors === 'number' && s.consecutiveErrors >= 0 ? s.consecutiveErrors : 0,
1900
+ lastRunAtMs: typeof s.lastRunAtMs === 'number' ? s.lastRunAtMs : undefined,
1901
+ lastRunStatus: typeof s.lastRunStatus === 'string' ? s.lastRunStatus : undefined,
1902
+ };
1903
+ const prev = this.heartbeatHealth;
1904
+ const changed = !prev ||
1905
+ prev.consecutiveErrors !== next.consecutiveErrors ||
1906
+ prev.lastRunAtMs !== next.lastRunAtMs ||
1907
+ prev.lastRunStatus !== next.lastRunStatus;
1908
+ this.heartbeatHealth = next;
1909
+ this.heartbeatHealthReadAtMs = Date.now();
1910
+ if (changed) {
1911
+ if (next.consecutiveErrors > 0) {
1912
+ logger.warn(TAG, `Heartbeat health: ${next.consecutiveErrors} consecutive error(s), lastStatus=${next.lastRunStatus ?? 'unknown'}`);
1913
+ }
1914
+ this.emitAgentState();
1915
+ }
1916
+ }
1917
+ catch {
1918
+ /* cron.list unavailable (version/scope) — fail open, keep last-known */
1919
+ }
1920
+ finally {
1921
+ this.heartbeatHealthRefreshing = false;
1922
+ }
1923
+ }
1924
+ /** Kick a lazy heartbeat-health refresh when the cache is stale and no
1925
+ * refresh is in flight. Fire-and-forget: the RPC completion re-emits
1926
+ * agent_state, so callers stay synchronous. */
1927
+ maybeRefreshHeartbeatHealth() {
1928
+ if (this.heartbeatHealthRefreshing)
1929
+ return;
1930
+ if (Date.now() - this.heartbeatHealthReadAtMs < 120_000)
1931
+ return;
1932
+ void this.refreshHeartbeatHealth();
1933
+ }
1802
1934
  /** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
1803
1935
  mergeAgentIdentity(found) {
1804
1936
  let changed = false;
@@ -1856,15 +1988,9 @@ export class GatewayProvider {
1856
1988
  ].filter((p) => !!p);
1857
1989
  for (const p of candidates) {
1858
1990
  try {
1859
- const txt = readFileSync(p, 'utf8');
1860
- const m = txt.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
1861
- let name = m?.[1]?.trim();
1862
- if (!name)
1863
- continue;
1864
- name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim(); // strip stray markdown emphasis
1865
- if (!name || /^\d+$/.test(name) || /\btbd\b|fill this in/i.test(name))
1866
- continue;
1867
- return name;
1991
+ const name = parseIdentityName(readFileSync(p, 'utf8'));
1992
+ if (name)
1993
+ return name;
1868
1994
  }
1869
1995
  catch {
1870
1996
  /* not found / unreadable — try the next candidate */
@@ -1872,6 +1998,31 @@ export class GatewayProvider {
1872
1998
  }
1873
1999
  return undefined;
1874
2000
  }
2001
+ /**
2002
+ * Keep the display name fresh WITHOUT waiting for a reconnect.
2003
+ *
2004
+ * Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
2005
+ * written minutes-to-days AFTER the bridge connected. Resolving only on
2006
+ * (re)connect meant the header served the unfilled template for the whole
2007
+ * onboarding session (2026-07-26: rig showed `(pick something you like)` for
2008
+ * ~24h after the agent wrote `Rook`). This is a small local file read, so a
2009
+ * 60s TTL keeps it honest at negligible cost — unlike the model, which needs
2010
+ * an RPC and legitimately only changes across a gateway restart.
2011
+ *
2012
+ * Called from buildAgentState(), so a newly-resolved name rides the payload
2013
+ * being built right now — no extra emit needed.
2014
+ */
2015
+ maybeRefreshAgentName() {
2016
+ const now = Date.now();
2017
+ if (now - this.agentNameReadAtMs < AGENT_NAME_TTL_MS)
2018
+ return;
2019
+ this.agentNameReadAtMs = now;
2020
+ const name = this.resolveAgentNameFromIdentity();
2021
+ // Fail-open: an unreadable/placeholder file keeps the last known good name.
2022
+ if (name && this.mergeAgentIdentity({ name })) {
2023
+ logger.info(TAG, `Agent identity updated: name=${name} (IDENTITY.md re-read)`);
2024
+ }
2025
+ }
1875
2026
  /**
1876
2027
  * Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
1877
2028
  * from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
@@ -1934,6 +2085,11 @@ export class GatewayProvider {
1934
2085
  }
1935
2086
  /** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
1936
2087
  buildAgentState() {
2088
+ // Lazily keep the heartbeat-failure signal fresh (fire-and-forget, 120s).
2089
+ this.maybeRefreshHeartbeatHealth();
2090
+ // Lazily re-read IDENTITY.md (60s) so a name filled in after connect shows up.
2091
+ this.maybeRefreshAgentName();
2092
+ const hb = this.heartbeatHealth;
1937
2093
  return {
1938
2094
  mode: this.agentMode,
1939
2095
  strategy: this.lastKnownStrategy,
@@ -1948,6 +2104,18 @@ export class GatewayProvider {
1948
2104
  errorRate: this.eventParser?.getErrorRate() ?? 0,
1949
2105
  thinkingInterval: this.resolveHeartbeatSeconds() ?? 1800,
1950
2106
  gatewayWs: this.wsClient?.getState() ?? 'disconnected',
2107
+ // Conditional spread: absent until cron.list resolves the beat's state,
2108
+ // so the payload stays byte-identical to the pre-feature shape when the
2109
+ // gateway/version doesn't expose it.
2110
+ ...(hb
2111
+ ? {
2112
+ heartbeatHealth: {
2113
+ consecutiveErrors: hb.consecutiveErrors,
2114
+ ...(hb.lastRunAtMs !== undefined ? { lastRunAtMs: hb.lastRunAtMs } : {}),
2115
+ ...(hb.lastRunStatus !== undefined ? { lastRunStatus: hb.lastRunStatus } : {}),
2116
+ },
2117
+ }
2118
+ : {}),
1951
2119
  },
1952
2120
  };
1953
2121
  }
@@ -1994,12 +2162,34 @@ export class GatewayProvider {
1994
2162
  const IMPOSSIBLE_DRAWDOWN = -0.5;
1995
2163
  const REQUIRED_RED_STREAK = 3;
1996
2164
  const MIN_RED_PERSISTENCE_MS = 25_000;
2165
+ /** Ceiling on the issue #248 torn-snapshot deferral — see below. */
2166
+ const MAX_TORN_SNAPSHOT_DEFER_MS = 5 * 60_000;
1997
2167
  if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
1998
2168
  if (this.redZoneStreak === 0)
1999
2169
  this.redZoneSince = Date.now();
2000
2170
  this.redZoneStreak++;
2001
2171
  const redForMs = Date.now() - this.redZoneSince;
2002
2172
  const survivedPositionsRefresh = this.positionsUpdatedAt > this.redZoneSince;
2173
+ // ★ issue #248: `survivedPositionsRefresh` proves a refresh HAPPENED, not
2174
+ // that it INCORPORATED the fill that debited the wallet. When a fill has
2175
+ // landed since the last positions refresh the snapshot is torn BY
2176
+ // DEFINITION — paper deducts full notional at open, so equity is
2177
+ // understated by exactly that notional and any entry >2.5% of NAV reads
2178
+ // as RED. Measured on a live box: entries of 8.25% / 8.11% of NAV
2179
+ // produced -8.09% / -7.93% verdicts 4s after each fill, on an account
2180
+ // that was UP on the day. This condition can only ever WIDEN deferral —
2181
+ // it never licenses a flatten the old code would have refused.
2182
+ const positionsCoverLatestFill = this.lastFillAt === 0 || this.positionsUpdatedAt > this.lastFillAt;
2183
+ // ...but BOUND that deferral. The tear resolves within one positions poll
2184
+ // (~10s), so a RED verdict still standing after MAX_TORN_SNAPSHOT_DEFER_MS
2185
+ // is not a snapshot artifact and must be honoured. Without a ceiling, a
2186
+ // flapping synthetic-fill detector would keep `lastFillAt` ahead of every
2187
+ // refresh and disarm the auto-flatten INDEFINITELY — and that detector is
2188
+ // known to fabricate (it invented a duplicate fill 8s after a real one on
2189
+ // 2026-07-25). Disarming a safety control on the word of an unreliable
2190
+ // component is the worse failure direction, so the tear only ever BUYS
2191
+ // TIME; past the ceiling the ordinary #213 debounce governs again.
2192
+ const tornSnapshotBlocks = !positionsCoverLatestFill && redForMs < MAX_TORN_SNAPSHOT_DEFER_MS;
2003
2193
  if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
2004
2194
  logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
2005
2195
  }
@@ -2011,8 +2201,9 @@ export class GatewayProvider {
2011
2201
  }
2012
2202
  else if (this.redZoneStreak < REQUIRED_RED_STREAK ||
2013
2203
  redForMs < MIN_RED_PERSISTENCE_MS ||
2014
- !survivedPositionsRefresh) {
2015
- logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — deferring auto-flatten until the verdict persists (streak ${this.redZoneStreak}/${REQUIRED_RED_STREAK}, ${Math.round(redForMs / 1000)}s/${MIN_RED_PERSISTENCE_MS / 1000}s, survivedPositionsRefresh=${survivedPositionsRefresh}). A fresh entry's notional reads as phantom drawdown until the position snapshot catches up (issue #213); a real drawdown will persist and flatten on a later evaluation.`);
2204
+ !survivedPositionsRefresh ||
2205
+ tornSnapshotBlocks) {
2206
+ logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — deferring auto-flatten until the verdict persists (streak ${this.redZoneStreak}/${REQUIRED_RED_STREAK}, ${Math.round(redForMs / 1000)}s/${MIN_RED_PERSISTENCE_MS / 1000}s, survivedPositionsRefresh=${survivedPositionsRefresh}, positionsCoverLatestFill=${positionsCoverLatestFill}, tornSnapshotBlocks=${tornSnapshotBlocks}). A fresh entry's notional reads as phantom drawdown until the position snapshot catches up (issues #213/#248); a real drawdown will persist and flatten on a later evaluation.`);
2016
2207
  }
2017
2208
  else {
2018
2209
  logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
@@ -2159,7 +2350,13 @@ export class GatewayProvider {
2159
2350
  const url = this.config.intelligenceUrl;
2160
2351
  const token = this.config.connectionToken;
2161
2352
  if (!url || !token) {
2162
- 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.');
2163
2360
  return;
2164
2361
  }
2165
2362
  const direction = entry.side === 'long' ? 'LONG' : 'SHORT';
@@ -2171,7 +2368,7 @@ export class GatewayProvider {
2171
2368
  const durationSeconds = Math.floor((Date.now() - new Date(entry.entryTime).getTime()) / 1000);
2172
2369
  const tradeResult = {
2173
2370
  missionId: entry.missionId ?? `trade-${Date.now()}`,
2174
- symbol: symbol.replace('/', ''), // BTC/USDT → BTCUSDT for Intelligence API
2371
+ symbol: this.toIntelSymbol(symbol), // venue-aware: BTC/USDT → BTCUSDT, BTC/USDC HL_BTC
2175
2372
  direction,
2176
2373
  strategy: this.lastKnownStrategy.name,
2177
2374
  regime: entry.regime ?? this.currentRegime,
@@ -2256,9 +2453,9 @@ export class GatewayProvider {
2256
2453
  }
2257
2454
  }
2258
2455
  // ---- Intelligence service polling ----
2259
- /** Symbol formatted for intelligence API (BTC/USDT BTCUSDT). */
2456
+ /** The configured symbol as the intel DB spells it. */
2260
2457
  get intelligenceSymbol() {
2261
- return this.config.symbol.replace('/', '');
2458
+ return this.toIntelSymbol(this.config.symbol);
2262
2459
  }
2263
2460
  /** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
2264
2461
  startIntelligencePoller(name, endpoint, intervalMs, onData, intervalRef, delayFirstMs = 0) {
@@ -2270,6 +2467,21 @@ export class GatewayProvider {
2270
2467
  }
2271
2468
  return;
2272
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
+ }
2273
2485
  const symbol = this.intelligenceSymbol;
2274
2486
  const fullUrl = `${url}/api/${endpoint}/${symbol}`;
2275
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) {
package/bridge/types.d.ts CHANGED
@@ -54,6 +54,25 @@ export interface OrderData {
54
54
  * paper / legacy paths (treated as a working order → cancellable).
55
55
  */
56
56
  protective?: boolean;
57
+ /** Limit price. Undefined for market and trigger-only orders. */
58
+ price?: number;
59
+ /**
60
+ * Trigger price for a stop / take-profit order — Binance `stopPrice`,
61
+ * Hyperliquid `triggerPx`.
62
+ *
63
+ * ★ Without this the dashboard cannot see a resting protective leg AT ALL:
64
+ * `type` is collapsed to MARKET|LIMIT here, so a STOP_MARKET is
65
+ * indistinguishable from a market order on the wire. That is what left an HL
66
+ * micro-live book — every position bracketed on the exchange — drawing an
67
+ * entry line and nothing else, under a false "NO STOP" alarm.
68
+ */
69
+ stopPrice?: number;
70
+ /**
71
+ * Which protective leg this order IS, normalised at the venue boundary so no
72
+ * consumer has to parse venue type strings ('STOP_MARKET' on Binance,
73
+ * 'Stop Market' on Hyperliquid). Undefined for working entry orders.
74
+ */
75
+ protectiveRole?: 'stop' | 'target';
57
76
  }
58
77
  export interface FillData {
59
78
  id: string;
@@ -103,6 +122,17 @@ export interface AgentStateData {
103
122
  thinkingInterval: number;
104
123
  /** Gateway WebSocket state — helps diagnose chat delivery failures */
105
124
  gatewayWs?: string;
125
+ /** Heartbeat-cron health from `cron.list`. Absent on gateway versions
126
+ * that don't expose cron state (byte-identical to pre-feature). Lets the
127
+ * dashboard surface a failing/stale beat instead of a silent gap. */
128
+ heartbeatHealth?: {
129
+ /** Consecutive failed runs; >0 means the beat isn't delivering. */
130
+ consecutiveErrors: number;
131
+ /** Epoch ms of the last run start (any outcome). */
132
+ lastRunAtMs?: number;
133
+ /** Last run outcome, e.g. "ok" | "error". */
134
+ lastRunStatus?: string;
135
+ };
106
136
  };
107
137
  };
108
138
  timestamp: string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
3
+ *
4
+ * Extracted from GatewayProvider so the placeholder rules are unit-testable
5
+ * against the real template text — a placeholder that slips through is shown
6
+ * to the user AS the agent's name, which is exactly the bug this guards
7
+ * (2026-07-26: a dashboard header read `(pick something you like)` while the
8
+ * file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
9
+ */
10
+ /**
11
+ * Extract `- **Name:** <X>` from IDENTITY.md markdown.
12
+ *
13
+ * Returns `undefined` when the file has no name, the value is an unfilled
14
+ * template placeholder, or it looks like an id rather than a name — callers
15
+ * fail open to their own UI placeholder.
16
+ *
17
+ * The value may sit on the line BELOW the label (the template's own layout):
18
+ *
19
+ * - **Name:**
20
+ * Rook
21
+ *
22
+ * which the `\s*` between `**` and the capture spans.
23
+ */
24
+ export declare function parseIdentityName(markdown: string): string | undefined;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
3
+ *
4
+ * Extracted from GatewayProvider so the placeholder rules are unit-testable
5
+ * against the real template text — a placeholder that slips through is shown
6
+ * to the user AS the agent's name, which is exactly the bug this guards
7
+ * (2026-07-26: a dashboard header read `(pick something you like)` while the
8
+ * file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
9
+ */
10
+ /**
11
+ * Values the template ships (or a half-filled file leaves behind) that must
12
+ * never reach the UI. Matched AFTER markdown emphasis is stripped.
13
+ */
14
+ const PLACEHOLDER_PATTERNS = [
15
+ /\btbd\b/i,
16
+ /fill this in/i,
17
+ // OpenClaw's own template values are parenthesised prompts, e.g.
18
+ // _(pick something you like)_
19
+ // _(AI? robot? familiar? ghost in the machine? something weirder?)_
20
+ // _(your signature — pick one that feels right)_
21
+ // Any value that is ENTIRELY a bracketed aside is a prompt, not a name.
22
+ /^\(.*\)$/s,
23
+ /^\[.*\]$/s,
24
+ /^<.*>$/s,
25
+ ];
26
+ /**
27
+ * Extract `- **Name:** <X>` from IDENTITY.md markdown.
28
+ *
29
+ * Returns `undefined` when the file has no name, the value is an unfilled
30
+ * template placeholder, or it looks like an id rather than a name — callers
31
+ * fail open to their own UI placeholder.
32
+ *
33
+ * The value may sit on the line BELOW the label (the template's own layout):
34
+ *
35
+ * - **Name:**
36
+ * Rook
37
+ *
38
+ * which the `\s*` between `**` and the capture spans.
39
+ */
40
+ export function parseIdentityName(markdown) {
41
+ const m = markdown.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
42
+ let name = m?.[1]?.trim();
43
+ if (!name)
44
+ return undefined;
45
+ // Strip stray markdown emphasis (the template wraps placeholders in `_`).
46
+ name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim();
47
+ if (!name)
48
+ return undefined;
49
+ if (/^\d+$/.test(name))
50
+ return undefined; // an id, not a name
51
+ if (PLACEHOLDER_PATTERNS.some((re) => re.test(name)))
52
+ return undefined;
53
+ return name;
54
+ }