@reefclaw/openclaw-plugin 0.1.12 → 0.1.13

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.
@@ -67,6 +67,25 @@ export declare function mapCcxtType(ccxtType: string): 'MARKET' | 'LIMIT';
67
67
  * real protective signals; a plain LIMIT/MARKET entry has none → false.
68
68
  */
69
69
  export declare function isProtectiveOrder(ccxt: CcxtOrder): boolean;
70
+ /**
71
+ * The trigger price of a stop / take-profit order, wherever the venue put it.
72
+ *
73
+ * Verified against live payloads: Binance USD-M Futures uses unified
74
+ * `stopPrice` (raw `info.stopPrice`); Hyperliquid's `frontendOpenOrders` rows
75
+ * carry `info.triggerPx` alongside `isTrigger: true` and
76
+ * `orderType: 'Stop Market' | 'Take Profit Market'`, which ccxt surfaces as
77
+ * `triggerPrice`.
78
+ */
79
+ export declare function extractTriggerPrice(ccxt: CcxtOrder): number | undefined;
80
+ /**
81
+ * Which protective leg an order is, or undefined when it is a working order.
82
+ *
83
+ * Venue type strings differ ('STOP_MARKET' / 'TAKE_PROFIT_MARKET' on Binance,
84
+ * 'Stop Market' / 'Take Profit Market' on Hyperliquid), so normalise here and
85
+ * let every consumer read one enum. Take-profit is tested FIRST because
86
+ * 'TAKE_PROFIT_MARKET' must never fall into a substring test for 'STOP'.
87
+ */
88
+ export declare function classifyProtectiveRole(ccxt: CcxtOrder): 'stop' | 'target' | undefined;
70
89
  export declare function mapCcxtOrder(ccxt: CcxtOrder): OrderData | null;
71
90
  /**
72
91
  * Build a FillData from a CCXT order (when the order has fills).
@@ -52,6 +52,49 @@ export function isProtectiveOrder(ccxt) {
52
52
  return true;
53
53
  return false;
54
54
  }
55
+ /** Positive finite number from a unified field or a raw string (venues send both). */
56
+ function positiveNum(v) {
57
+ const n = typeof v === 'string' ? Number(v) : v;
58
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
59
+ }
60
+ /**
61
+ * The trigger price of a stop / take-profit order, wherever the venue put it.
62
+ *
63
+ * Verified against live payloads: Binance USD-M Futures uses unified
64
+ * `stopPrice` (raw `info.stopPrice`); Hyperliquid's `frontendOpenOrders` rows
65
+ * carry `info.triggerPx` alongside `isTrigger: true` and
66
+ * `orderType: 'Stop Market' | 'Take Profit Market'`, which ccxt surfaces as
67
+ * `triggerPrice`.
68
+ */
69
+ export function extractTriggerPrice(ccxt) {
70
+ const o = ccxt;
71
+ return (positiveNum(o.stopPrice) ??
72
+ positiveNum(o.triggerPrice) ??
73
+ positiveNum(o.info?.stopPrice) ??
74
+ positiveNum(o.info?.triggerPx));
75
+ }
76
+ /**
77
+ * Which protective leg an order is, or undefined when it is a working order.
78
+ *
79
+ * Venue type strings differ ('STOP_MARKET' / 'TAKE_PROFIT_MARKET' on Binance,
80
+ * 'Stop Market' / 'Take Profit Market' on Hyperliquid), so normalise here and
81
+ * let every consumer read one enum. Take-profit is tested FIRST because
82
+ * 'TAKE_PROFIT_MARKET' must never fall into a substring test for 'STOP'.
83
+ */
84
+ export function classifyProtectiveRole(ccxt) {
85
+ if (!isProtectiveOrder(ccxt))
86
+ return undefined;
87
+ const o = ccxt;
88
+ const rawType = String(o.info?.origType ?? o.info?.orderType ?? o.info?.type ?? o.type ?? '').toUpperCase();
89
+ if (rawType.includes('TAKE_PROFIT') || rawType.includes('TAKE PROFIT'))
90
+ return 'target';
91
+ if (rawType.includes('STOP'))
92
+ return 'stop';
93
+ // A resting reduce-only LIMIT with no trigger is a take-profit (split-TP legs).
94
+ if (String(o.type ?? '').toLowerCase() === 'limit')
95
+ return 'target';
96
+ return undefined;
97
+ }
55
98
  export function mapCcxtOrder(ccxt) {
56
99
  if (!ccxt.id || typeof ccxt.id !== 'string') {
57
100
  logger.warn(TAG, `CCXT order missing valid ID: ${JSON.stringify(ccxt.id)}`);
@@ -68,6 +111,9 @@ export function mapCcxtOrder(ccxt) {
68
111
  const now = new Date().toISOString();
69
112
  const ts = ccxt.datetime ?? (ccxt.timestamp ? new Date(ccxt.timestamp).toISOString() : now);
70
113
  const protective = isProtectiveOrder(ccxt);
114
+ const price = positiveNum(ccxt.price);
115
+ const stopPrice = extractTriggerPrice(ccxt);
116
+ const protectiveRole = classifyProtectiveRole(ccxt);
71
117
  return {
72
118
  id: ccxt.id,
73
119
  symbol: ccxt.symbol,
@@ -81,6 +127,12 @@ export function mapCcxtOrder(ccxt) {
81
127
  createdAt: ts,
82
128
  updatedAt: ts,
83
129
  ...(protective && { protective: true }),
130
+ // Prices the dashboard draws its stop/target lines from. A resting leg is
131
+ // exchange truth and exists on every venue, so it is the source that
132
+ // survives a missing ledger row or a venue with no metadata map.
133
+ ...(price !== undefined && { price }),
134
+ ...(stopPrice !== undefined && { stopPrice }),
135
+ ...(protectiveRole && { protectiveRole }),
84
136
  };
85
137
  }
86
138
  /**
@@ -1,5 +1,6 @@
1
1
  export declare const HEARTBEAT_CRON_NAME = "reefclaw-heartbeat";
2
2
  export declare const HEARTBEAT_EVERY_MS: number;
3
+ export declare const HEARTBEAT_TIMEOUT_SECONDS = 780;
3
4
  export declare const HEARTBEAT_MESSAGE: string;
4
5
  export interface CronRpcClient {
5
6
  sendRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
@@ -16,7 +16,18 @@
16
16
  import { existsSync, mkdirSync, writeFileSync } from 'fs';
17
17
  import { dirname } from 'path';
18
18
  export const HEARTBEAT_CRON_NAME = 'reefclaw-heartbeat';
19
+ // 15m is the default. A disciplined beat (early-exit on no-trade + trading
20
+ // tools only — see SKILL.md) finishes in a couple of minutes even on small
21
+ // models, so 15m is comfortable; the 2026-07-24 timeouts were caused by the
22
+ // agent's "coding" tool profile making it WANDER (a 9-min beat), not by the
23
+ // cadence — the funnel + tool discipline are the fix, not a slower schedule.
24
+ // Existing installs keep their cadence (once-per-install marker); operators
25
+ // change it any time via `openclaw cron edit <id> --every <duration>`.
19
26
  export const HEARTBEAT_EVERY_MS = 15 * 60 * 1000;
27
+ // Explicit per-run cap. The server does NOT default payload.timeoutSeconds
28
+ // (verified empirically on 2026.7.1-2: a cron.add without it stores none) —
29
+ // 780s is the field-proven value from the 2026-07-22 small-box mitigations.
30
+ export const HEARTBEAT_TIMEOUT_SECONDS = 780;
20
31
  // Byte-identical to the message the setup-time CLI path created, so new jobs
21
32
  // match every existing install's job.
22
33
  export const HEARTBEAT_MESSAGE = 'Heartbeat. Run SESSION START mandatory checks, then the full Decision Loop (Steps 0-9) from SKILL.md. ' +
@@ -74,7 +85,17 @@ export async function ensureHeartbeatCron(opts) {
74
85
  schedule: { kind: 'every', everyMs: HEARTBEAT_EVERY_MS },
75
86
  sessionTarget: 'isolated',
76
87
  wakeMode: 'now',
77
- payload: { kind: 'agentTurn', message: HEARTBEAT_MESSAGE },
88
+ // payload.timeoutSeconds is the stored shape (verified via the CLI's own
89
+ // cron.add on 2026.7.1-2). No toolsAllow: absent = all-tools-allowed,
90
+ // which is future-proof — an explicit allowlist freezes at creation and
91
+ // silently excludes every plugin tool shipped later. Beat tool
92
+ // discipline (no subagents/web/media during heartbeats) lives in
93
+ // SKILL.md, which updates on every bridge start.
94
+ payload: {
95
+ kind: 'agentTurn',
96
+ message: HEARTBEAT_MESSAGE,
97
+ timeoutSeconds: HEARTBEAT_TIMEOUT_SECONDS,
98
+ },
78
99
  };
79
100
  let delivery = 'announce';
80
101
  try {
@@ -40,12 +40,21 @@ export declare class GatewayProvider implements OpenClawProvider {
40
40
  private agentIdentity;
41
41
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
42
42
  private loggedAgentIdentityProbe;
43
+ /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
44
+ private agentNameReadAtMs;
43
45
  /** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
44
46
  private heartbeatCronEnsureStarted;
45
47
  /** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
46
48
  private heartbeatSeconds?;
47
49
  private heartbeatReadAtMs;
48
50
  private loggedHeartbeat;
51
+ /** Heartbeat-job health read from `cron.list` (RPC — the filesystem jobs.json
52
+ * is absent on 2026.7.x). Surfaced so the dashboard shows a failing/stale
53
+ * beat instead of a silent gap (a beat failed 10× over ~2 days undetected,
54
+ * 2026-07-24). undefined until first resolved; refreshed lazily (120s). */
55
+ private heartbeatHealth?;
56
+ private heartbeatHealthReadAtMs;
57
+ private heartbeatHealthRefreshing;
49
58
  private lastTicker;
50
59
  private positions;
51
60
  private balance;
@@ -107,6 +116,13 @@ export declare class GatewayProvider implements OpenClawProvider {
107
116
  private currentRegime;
108
117
  /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
109
118
  private lastTradeTs;
119
+ /** Epoch ms of the most recent LIVE fill observed this process (issue #248).
120
+ * Deliberately separate from `lastTradeTs`: that one is a display string and
121
+ * is also seeded from the historical trades ledger at startup, which must
122
+ * NOT count here — this field exists solely to prove whether the current
123
+ * `positions` snapshot predates a fill, and a historical seed would make an
124
+ * untorn snapshot look torn forever. 0 = no fill seen yet this process. */
125
+ private lastFillAt;
110
126
  /** Tracks open position entries for computing trade results on close.
111
127
  * Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
112
128
  private openTradeEntries;
@@ -274,6 +290,24 @@ export declare class GatewayProvider implements OpenClawProvider {
274
290
  * so the dashboard updates without waiting for the next periodic tick.
275
291
  */
276
292
  private refreshAgentIdentity;
293
+ /**
294
+ * Read the heartbeat cron job's health from `cron.list` (RPC — the
295
+ * filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
296
+ * Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
297
+ * job and caches it. Best-effort, fail-open (a version/scope that hides
298
+ * cron.list simply leaves health undefined → the dashboard shows nothing new,
299
+ * byte-identical to before). Re-emits agent_state when the value changes so a
300
+ * newly-failing beat surfaces without waiting for the next tick.
301
+ *
302
+ * Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
303
+ * (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
304
+ * `consecutiveErrors` was sitting right here in the cron state the whole time.
305
+ */
306
+ private refreshHeartbeatHealth;
307
+ /** Kick a lazy heartbeat-health refresh when the cache is stale and no
308
+ * refresh is in flight. Fire-and-forget: the RPC completion re-emits
309
+ * agent_state, so callers stay synchronous. */
310
+ private maybeRefreshHeartbeatHealth;
277
311
  /** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
278
312
  private mergeAgentIdentity;
279
313
  /**
@@ -294,6 +328,21 @@ export declare class GatewayProvider implements OpenClawProvider {
294
328
  * unfilled placeholder, or the value looks like an id rather than a name.
295
329
  */
296
330
  private resolveAgentNameFromIdentity;
331
+ /**
332
+ * Keep the display name fresh WITHOUT waiting for a reconnect.
333
+ *
334
+ * Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
335
+ * written minutes-to-days AFTER the bridge connected. Resolving only on
336
+ * (re)connect meant the header served the unfilled template for the whole
337
+ * onboarding session (2026-07-26: rig showed `(pick something you like)` for
338
+ * ~24h after the agent wrote `Rook`). This is a small local file read, so a
339
+ * 60s TTL keeps it honest at negligible cost — unlike the model, which needs
340
+ * an RPC and legitimately only changes across a gateway restart.
341
+ *
342
+ * Called from buildAgentState(), so a newly-resolved name rides the payload
343
+ * being built right now — no extra emit needed.
344
+ */
345
+ private maybeRefreshAgentName;
297
346
  /**
298
347
  * Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
299
348
  * from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
@@ -11,7 +11,8 @@ import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
11
11
  import { discoverTools } from '../gateway/tool-discovery.js';
12
12
  import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, extractPlannedLevels, } from '../gateway/event-parser.js';
13
13
  import { Poller } from '../gateway/poller.js';
14
- import { ensureHeartbeatCron } from '../gateway/heartbeat-cron.js';
14
+ import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
15
+ import { parseIdentityName } from '../utils/identity-name.js';
15
16
  import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
16
17
  import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
17
18
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
@@ -130,6 +131,8 @@ function saveSessionStartNav(nav) {
130
131
  }
131
132
  // ---- Agent state emission interval ----
132
133
  const AGENT_STATE_INTERVAL_MS = 5_000;
134
+ /** How often IDENTITY.md is re-read for the agent's display name (local file read). */
135
+ const AGENT_NAME_TTL_MS = 60_000;
133
136
  // ---- GatewayProvider ----
134
137
  export class GatewayProvider {
135
138
  config;
@@ -154,12 +157,21 @@ export class GatewayProvider {
154
157
  agentIdentity = {};
155
158
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
156
159
  loggedAgentIdentityProbe = false;
160
+ /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
161
+ agentNameReadAtMs = 0;
157
162
  /** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
158
163
  heartbeatCronEnsureStarted = false;
159
164
  /** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
160
165
  heartbeatSeconds;
161
166
  heartbeatReadAtMs = 0;
162
167
  loggedHeartbeat = false;
168
+ /** Heartbeat-job health read from `cron.list` (RPC — the filesystem jobs.json
169
+ * is absent on 2026.7.x). Surfaced so the dashboard shows a failing/stale
170
+ * beat instead of a silent gap (a beat failed 10× over ~2 days undetected,
171
+ * 2026-07-24). undefined until first resolved; refreshed lazily (120s). */
172
+ heartbeatHealth;
173
+ heartbeatHealthReadAtMs = 0;
174
+ heartbeatHealthRefreshing = false;
163
175
  lastTicker = null;
164
176
  positions = [];
165
177
  balance = { currency: 'USDT', total: 0, available: 0, locked: 0 };
@@ -230,6 +242,13 @@ export class GatewayProvider {
230
242
  // ---- Trade tracking ----
231
243
  /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
232
244
  lastTradeTs = null;
245
+ /** Epoch ms of the most recent LIVE fill observed this process (issue #248).
246
+ * Deliberately separate from `lastTradeTs`: that one is a display string and
247
+ * is also seeded from the historical trades ledger at startup, which must
248
+ * NOT count here — this field exists solely to prove whether the current
249
+ * `positions` snapshot predates a fill, and a historical seed would make an
250
+ * untorn snapshot look torn forever. 0 = no fill seen yet this process. */
251
+ lastFillAt = 0;
233
252
  /** Tracks open position entries for computing trade results on close.
234
253
  * Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
235
254
  openTradeEntries = new Map();
@@ -1152,6 +1171,10 @@ export class GatewayProvider {
1152
1171
  // Track last trade timestamp for agent state
1153
1172
  if (data.status === 'FILLED') {
1154
1173
  this.lastTradeTs = data.timestamp ?? new Date().toISOString();
1174
+ // A fill debits the paper wallet immediately; `positions` catches up
1175
+ // on its own poll. Record when, so the RED-zone guard can tell a torn
1176
+ // wallet/positions snapshot from a real drawdown (issue #248).
1177
+ this.lastFillAt = Date.now();
1155
1178
  }
1156
1179
  }
1157
1180
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1423,6 +1446,10 @@ export class GatewayProvider {
1423
1446
  };
1424
1447
  logger.info(TAG, `Synthetic fill: ${side} ${quantity} ${pos.symbol} @ ${fillPrice} (from position poll)`);
1425
1448
  this.lastTradeTs = now;
1449
+ // Same reasoning as the orderUpdate path (issue #248) — this one is derived
1450
+ // FROM a positions poll, so the snapshot already covers it and the guard
1451
+ // clears on the next refresh rather than latching.
1452
+ this.lastFillAt = Date.now();
1426
1453
  this.fire('orderUpdate', payload);
1427
1454
  // Track open entries for trade result reporting
1428
1455
  const tradeSide = pos.side === 'short' ? 'short' : 'long';
@@ -1799,6 +1826,70 @@ export class GatewayProvider {
1799
1826
  this.emitAgentState();
1800
1827
  }
1801
1828
  }
1829
+ /**
1830
+ * Read the heartbeat cron job's health from `cron.list` (RPC — the
1831
+ * filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
1832
+ * Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
1833
+ * job and caches it. Best-effort, fail-open (a version/scope that hides
1834
+ * cron.list simply leaves health undefined → the dashboard shows nothing new,
1835
+ * byte-identical to before). Re-emits agent_state when the value changes so a
1836
+ * newly-failing beat surfaces without waiting for the next tick.
1837
+ *
1838
+ * Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
1839
+ * (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
1840
+ * `consecutiveErrors` was sitting right here in the cron state the whole time.
1841
+ */
1842
+ async refreshHeartbeatHealth() {
1843
+ const ws = this.wsClient;
1844
+ if (!ws)
1845
+ return;
1846
+ this.heartbeatHealthRefreshing = true;
1847
+ try {
1848
+ const resp = await ws.sendRpc('cron.list', { includeDisabled: true });
1849
+ const jobs = resp?.jobs;
1850
+ if (!Array.isArray(jobs))
1851
+ return; // unknown shape — leave last-known
1852
+ const hb = jobs.find((j) => isHeartbeatLikeName(j?.name));
1853
+ const state = hb?.state;
1854
+ if (!state || typeof state !== 'object')
1855
+ return;
1856
+ const s = state;
1857
+ const next = {
1858
+ consecutiveErrors: typeof s.consecutiveErrors === 'number' && s.consecutiveErrors >= 0 ? s.consecutiveErrors : 0,
1859
+ lastRunAtMs: typeof s.lastRunAtMs === 'number' ? s.lastRunAtMs : undefined,
1860
+ lastRunStatus: typeof s.lastRunStatus === 'string' ? s.lastRunStatus : undefined,
1861
+ };
1862
+ const prev = this.heartbeatHealth;
1863
+ const changed = !prev ||
1864
+ prev.consecutiveErrors !== next.consecutiveErrors ||
1865
+ prev.lastRunAtMs !== next.lastRunAtMs ||
1866
+ prev.lastRunStatus !== next.lastRunStatus;
1867
+ this.heartbeatHealth = next;
1868
+ this.heartbeatHealthReadAtMs = Date.now();
1869
+ if (changed) {
1870
+ if (next.consecutiveErrors > 0) {
1871
+ logger.warn(TAG, `Heartbeat health: ${next.consecutiveErrors} consecutive error(s), lastStatus=${next.lastRunStatus ?? 'unknown'}`);
1872
+ }
1873
+ this.emitAgentState();
1874
+ }
1875
+ }
1876
+ catch {
1877
+ /* cron.list unavailable (version/scope) — fail open, keep last-known */
1878
+ }
1879
+ finally {
1880
+ this.heartbeatHealthRefreshing = false;
1881
+ }
1882
+ }
1883
+ /** Kick a lazy heartbeat-health refresh when the cache is stale and no
1884
+ * refresh is in flight. Fire-and-forget: the RPC completion re-emits
1885
+ * agent_state, so callers stay synchronous. */
1886
+ maybeRefreshHeartbeatHealth() {
1887
+ if (this.heartbeatHealthRefreshing)
1888
+ return;
1889
+ if (Date.now() - this.heartbeatHealthReadAtMs < 120_000)
1890
+ return;
1891
+ void this.refreshHeartbeatHealth();
1892
+ }
1802
1893
  /** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
1803
1894
  mergeAgentIdentity(found) {
1804
1895
  let changed = false;
@@ -1856,15 +1947,9 @@ export class GatewayProvider {
1856
1947
  ].filter((p) => !!p);
1857
1948
  for (const p of candidates) {
1858
1949
  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;
1950
+ const name = parseIdentityName(readFileSync(p, 'utf8'));
1951
+ if (name)
1952
+ return name;
1868
1953
  }
1869
1954
  catch {
1870
1955
  /* not found / unreadable — try the next candidate */
@@ -1872,6 +1957,31 @@ export class GatewayProvider {
1872
1957
  }
1873
1958
  return undefined;
1874
1959
  }
1960
+ /**
1961
+ * Keep the display name fresh WITHOUT waiting for a reconnect.
1962
+ *
1963
+ * Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
1964
+ * written minutes-to-days AFTER the bridge connected. Resolving only on
1965
+ * (re)connect meant the header served the unfilled template for the whole
1966
+ * onboarding session (2026-07-26: rig showed `(pick something you like)` for
1967
+ * ~24h after the agent wrote `Rook`). This is a small local file read, so a
1968
+ * 60s TTL keeps it honest at negligible cost — unlike the model, which needs
1969
+ * an RPC and legitimately only changes across a gateway restart.
1970
+ *
1971
+ * Called from buildAgentState(), so a newly-resolved name rides the payload
1972
+ * being built right now — no extra emit needed.
1973
+ */
1974
+ maybeRefreshAgentName() {
1975
+ const now = Date.now();
1976
+ if (now - this.agentNameReadAtMs < AGENT_NAME_TTL_MS)
1977
+ return;
1978
+ this.agentNameReadAtMs = now;
1979
+ const name = this.resolveAgentNameFromIdentity();
1980
+ // Fail-open: an unreadable/placeholder file keeps the last known good name.
1981
+ if (name && this.mergeAgentIdentity({ name })) {
1982
+ logger.info(TAG, `Agent identity updated: name=${name} (IDENTITY.md re-read)`);
1983
+ }
1984
+ }
1875
1985
  /**
1876
1986
  * Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
1877
1987
  * from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
@@ -1934,6 +2044,11 @@ export class GatewayProvider {
1934
2044
  }
1935
2045
  /** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
1936
2046
  buildAgentState() {
2047
+ // Lazily keep the heartbeat-failure signal fresh (fire-and-forget, 120s).
2048
+ this.maybeRefreshHeartbeatHealth();
2049
+ // Lazily re-read IDENTITY.md (60s) so a name filled in after connect shows up.
2050
+ this.maybeRefreshAgentName();
2051
+ const hb = this.heartbeatHealth;
1937
2052
  return {
1938
2053
  mode: this.agentMode,
1939
2054
  strategy: this.lastKnownStrategy,
@@ -1948,6 +2063,18 @@ export class GatewayProvider {
1948
2063
  errorRate: this.eventParser?.getErrorRate() ?? 0,
1949
2064
  thinkingInterval: this.resolveHeartbeatSeconds() ?? 1800,
1950
2065
  gatewayWs: this.wsClient?.getState() ?? 'disconnected',
2066
+ // Conditional spread: absent until cron.list resolves the beat's state,
2067
+ // so the payload stays byte-identical to the pre-feature shape when the
2068
+ // gateway/version doesn't expose it.
2069
+ ...(hb
2070
+ ? {
2071
+ heartbeatHealth: {
2072
+ consecutiveErrors: hb.consecutiveErrors,
2073
+ ...(hb.lastRunAtMs !== undefined ? { lastRunAtMs: hb.lastRunAtMs } : {}),
2074
+ ...(hb.lastRunStatus !== undefined ? { lastRunStatus: hb.lastRunStatus } : {}),
2075
+ },
2076
+ }
2077
+ : {}),
1951
2078
  },
1952
2079
  };
1953
2080
  }
@@ -1994,12 +2121,34 @@ export class GatewayProvider {
1994
2121
  const IMPOSSIBLE_DRAWDOWN = -0.5;
1995
2122
  const REQUIRED_RED_STREAK = 3;
1996
2123
  const MIN_RED_PERSISTENCE_MS = 25_000;
2124
+ /** Ceiling on the issue #248 torn-snapshot deferral — see below. */
2125
+ const MAX_TORN_SNAPSHOT_DEFER_MS = 5 * 60_000;
1997
2126
  if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
1998
2127
  if (this.redZoneStreak === 0)
1999
2128
  this.redZoneSince = Date.now();
2000
2129
  this.redZoneStreak++;
2001
2130
  const redForMs = Date.now() - this.redZoneSince;
2002
2131
  const survivedPositionsRefresh = this.positionsUpdatedAt > this.redZoneSince;
2132
+ // ★ issue #248: `survivedPositionsRefresh` proves a refresh HAPPENED, not
2133
+ // that it INCORPORATED the fill that debited the wallet. When a fill has
2134
+ // landed since the last positions refresh the snapshot is torn BY
2135
+ // DEFINITION — paper deducts full notional at open, so equity is
2136
+ // understated by exactly that notional and any entry >2.5% of NAV reads
2137
+ // as RED. Measured on a live box: entries of 8.25% / 8.11% of NAV
2138
+ // produced -8.09% / -7.93% verdicts 4s after each fill, on an account
2139
+ // that was UP on the day. This condition can only ever WIDEN deferral —
2140
+ // it never licenses a flatten the old code would have refused.
2141
+ const positionsCoverLatestFill = this.lastFillAt === 0 || this.positionsUpdatedAt > this.lastFillAt;
2142
+ // ...but BOUND that deferral. The tear resolves within one positions poll
2143
+ // (~10s), so a RED verdict still standing after MAX_TORN_SNAPSHOT_DEFER_MS
2144
+ // is not a snapshot artifact and must be honoured. Without a ceiling, a
2145
+ // flapping synthetic-fill detector would keep `lastFillAt` ahead of every
2146
+ // refresh and disarm the auto-flatten INDEFINITELY — and that detector is
2147
+ // known to fabricate (it invented a duplicate fill 8s after a real one on
2148
+ // 2026-07-25). Disarming a safety control on the word of an unreliable
2149
+ // component is the worse failure direction, so the tear only ever BUYS
2150
+ // TIME; past the ceiling the ordinary #213 debounce governs again.
2151
+ const tornSnapshotBlocks = !positionsCoverLatestFill && redForMs < MAX_TORN_SNAPSHOT_DEFER_MS;
2003
2152
  if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
2004
2153
  logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
2005
2154
  }
@@ -2011,8 +2160,9 @@ export class GatewayProvider {
2011
2160
  }
2012
2161
  else if (this.redZoneStreak < REQUIRED_RED_STREAK ||
2013
2162
  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.`);
2163
+ !survivedPositionsRefresh ||
2164
+ tornSnapshotBlocks) {
2165
+ 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
2166
  }
2017
2167
  else {
2018
2168
  logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
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
+ }
@@ -18,8 +18,10 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
18
18
  fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
19
19
  /** Probe Binance USD-M FUTURES reachability from this host — the readiness
20
20
  * gate's core signal. Calls the futures-explicit implicit method so it hits
21
- * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
22
- * `api.binance.com`, since the public client doesn't set defaultType:'future').
21
+ * `fapi.binance.com` regardless of how the instance is configured. Keep it
22
+ * explicit even though the client now sets `defaultType: 'future'`: this
23
+ * probe must detect a futures-specific 451 geo-block, and it must not start
24
+ * silently probing spot if that option is ever changed.
23
25
  * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
24
26
  * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
25
27
  * noteBinanceError). Outcomes: