@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
@@ -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,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.
@@ -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 {
@@ -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
  }
@@ -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;
@@ -227,6 +243,20 @@ export declare class GatewayProvider implements OpenClawProvider {
227
243
  * the WS store; under weight pressure isStoreTrusted() flickers between
228
244
  * the two, so the raw symbol alternates poll-to-poll. */
229
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;
230
260
  private onPollerPositions;
231
261
  /**
232
262
  * Compare previous and current positions from polling.
@@ -263,6 +293,14 @@ export declare class GatewayProvider implements OpenClawProvider {
263
293
  private lastMarketStructure;
264
294
  private fetchFreshPositions;
265
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. */
266
304
  private fetchFreshOpenOrders;
267
305
  private fetchAgentHealth;
268
306
  /**
@@ -274,6 +312,24 @@ export declare class GatewayProvider implements OpenClawProvider {
274
312
  * so the dashboard updates without waiting for the next periodic tick.
275
313
  */
276
314
  private refreshAgentIdentity;
315
+ /**
316
+ * Read the heartbeat cron job's health from `cron.list` (RPC — the
317
+ * filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
318
+ * Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
319
+ * job and caches it. Best-effort, fail-open (a version/scope that hides
320
+ * cron.list simply leaves health undefined → the dashboard shows nothing new,
321
+ * byte-identical to before). Re-emits agent_state when the value changes so a
322
+ * newly-failing beat surfaces without waiting for the next tick.
323
+ *
324
+ * Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
325
+ * (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
326
+ * `consecutiveErrors` was sitting right here in the cron state the whole time.
327
+ */
328
+ private refreshHeartbeatHealth;
329
+ /** Kick a lazy heartbeat-health refresh when the cache is stale and no
330
+ * refresh is in flight. Fire-and-forget: the RPC completion re-emits
331
+ * agent_state, so callers stay synchronous. */
332
+ private maybeRefreshHeartbeatHealth;
277
333
  /** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
278
334
  private mergeAgentIdentity;
279
335
  /**
@@ -294,6 +350,21 @@ export declare class GatewayProvider implements OpenClawProvider {
294
350
  * unfilled placeholder, or the value looks like an id rather than a name.
295
351
  */
296
352
  private resolveAgentNameFromIdentity;
353
+ /**
354
+ * Keep the display name fresh WITHOUT waiting for a reconnect.
355
+ *
356
+ * Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
357
+ * written minutes-to-days AFTER the bridge connected. Resolving only on
358
+ * (re)connect meant the header served the unfilled template for the whole
359
+ * onboarding session (2026-07-26: rig showed `(pick something you like)` for
360
+ * ~24h after the agent wrote `Rook`). This is a small local file read, so a
361
+ * 60s TTL keeps it honest at negligible cost — unlike the model, which needs
362
+ * an RPC and legitimately only changes across a gateway restart.
363
+ *
364
+ * Called from buildAgentState(), so a newly-resolved name rides the payload
365
+ * being built right now — no extra emit needed.
366
+ */
367
+ private maybeRefreshAgentName;
297
368
  /**
298
369
  * Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
299
370
  * from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
@@ -338,7 +409,7 @@ export declare class GatewayProvider implements OpenClawProvider {
338
409
  * truthful-unknown beats confidently-wrong.
339
410
  */
340
411
  private seedLastTradeTs;
341
- /** Symbol formatted for intelligence API (BTC/USDT BTCUSDT). */
412
+ /** The configured symbol as the intel DB spells it. */
342
413
  private get intelligenceSymbol();
343
414
  /** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
344
415
  private startIntelligencePoller;