@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.
- package/bridge/gateway/event-parser.d.ts +19 -0
- package/bridge/gateway/event-parser.js +52 -0
- package/bridge/gateway/gateway-config.d.ts +16 -5
- package/bridge/gateway/gateway-config.js +68 -12
- package/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/bridge/gateway/heartbeat-cron.js +22 -1
- package/bridge/gateway/poller.js +18 -8
- package/bridge/providers/emergency-commands.d.ts +9 -1
- package/bridge/providers/emergency-commands.js +38 -1
- package/bridge/providers/gateway.d.ts +72 -1
- package/bridge/providers/gateway.js +241 -29
- package/bridge/providers/onboarding-commands.d.ts +8 -0
- package/bridge/providers/onboarding-commands.js +4 -4
- package/bridge/types.d.ts +30 -0
- package/bridge/utils/identity-name.d.ts +24 -0
- package/bridge/utils/identity-name.js +54 -0
- package/ccxt/binance-public.d.ts +21 -7
- package/ccxt/binance-public.js +70 -6
- package/config/operator-provenance.d.ts +6 -0
- package/config/operator-provenance.js +50 -0
- package/config/plugin-config-io.d.ts +15 -1
- package/config/plugin-config-io.js +24 -0
- package/index.js +216 -173
- package/ingest/event-loop-monitor.d.ts +11 -0
- package/ingest/event-loop-monitor.js +113 -0
- package/ingest/position-auto-capture.d.ts +5 -0
- package/ingest/position-auto-capture.js +14 -5
- package/ingest/readiness-reporter.d.ts +17 -6
- package/ingest/readiness-reporter.js +88 -9
- package/ingest/skill-version-reader.d.ts +16 -0
- package/ingest/skill-version-reader.js +64 -0
- package/live/approval-lifecycle.d.ts +30 -0
- package/live/approval-lifecycle.js +80 -0
- package/live/bracket-types.d.ts +9 -0
- package/live/live-adapter.d.ts +0 -1
- package/live/proposal-decision-listener.d.ts +20 -0
- package/live/proposal-decision-listener.js +211 -48
- package/onboarding/runtime.d.ts +34 -1
- package/onboarding/runtime.js +56 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/simulator/exchange-simulator.d.ts +45 -2
- package/simulator/exchange-simulator.js +96 -4
- package/simulator/types.d.ts +17 -0
- package/tools/attach-brackets.js +50 -1
- package/tools/create-order.d.ts +11 -0
- package/tools/create-order.js +23 -2
- package/tools/get-risk-summary.d.ts +4 -0
- package/tools/get-risk-summary.js +62 -23
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
- package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
- package/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/venues/hyperliquid/hl-brackets.js +45 -13
- package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/venues/hyperliquid/hl-fill-ingest.js +69 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/venues/hyperliquid/hl-live-adapter.js +155 -12
- package/venues/hyperliquid/hl-order.d.ts +35 -0
- package/venues/hyperliquid/hl-order.js +123 -0
- package/venues/hyperliquid/hl-position.d.ts +36 -0
- package/venues/hyperliquid/hl-position.js +127 -0
- package/venues/hyperliquid/hl-private.d.ts +20 -3
- package/venues/hyperliquid/hl-private.js +37 -6
- package/venues/hyperliquid/hl-public.d.ts +12 -5
- package/venues/hyperliquid/hl-public.js +24 -3
- package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- 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 (
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
||
|
|
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
|
-
||
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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 {
|
package/bridge/gateway/poller.js
CHANGED
|
@@ -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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
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;
|