@reefclaw/openclaw-plugin 0.1.7 → 0.1.9

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.
@@ -0,0 +1,27 @@
1
+ export declare const HEARTBEAT_CRON_NAME = "reefclaw-heartbeat";
2
+ export declare const HEARTBEAT_EVERY_MS: number;
3
+ export declare const HEARTBEAT_MESSAGE: string;
4
+ export interface CronRpcClient {
5
+ sendRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
6
+ }
7
+ /**
8
+ * Whether an existing cron job counts as "the heartbeat already exists".
9
+ * Union of the two matchers already in the codebase: the old setup path
10
+ * matched /reefclaw/, while resolveHeartbeatSeconds() (the cadence reader that
11
+ * drives the dashboard header) matches /heart\s*beat/. A job satisfying either
12
+ * must suppress creation — prod/wisekid boxes carry heartbeat jobs whose names
13
+ * predate the reefclaw- prefix, and a duplicate 15m heartbeat would double the
14
+ * agent's token burn.
15
+ */
16
+ export declare function isHeartbeatLikeName(name: unknown): boolean;
17
+ export type EnsureHeartbeatOutcome = 'already_ensured' | 'found_existing' | 'created' | 'failed';
18
+ /**
19
+ * Ensure a heartbeat cron exists. Non-fatal by contract: every failure path
20
+ * returns 'failed' rather than throwing, and only success writes the marker so
21
+ * a transient gateway error retries on the next boot.
22
+ */
23
+ export declare function ensureHeartbeatCron(opts: {
24
+ rpc: CronRpcClient;
25
+ markerPath: string;
26
+ log: (msg: string) => void;
27
+ }): Promise<EnsureHeartbeatOutcome>;
@@ -0,0 +1,85 @@
1
+ // Ensures the ReefClaw heartbeat cron job exists — once per install — via
2
+ // gateway RPC (`cron.list` / `cron.add`).
3
+ //
4
+ // This replaces the old setup-time shell-out to the openclaw CLI. The CLI's
5
+ // cron commands are themselves gateway RPC clients, so shelling out bought
6
+ // nothing except a subprocess + PATH dependency — and it ran at
7
+ // setup time, when the gateway is typically NOT up yet (setup's printed next
8
+ // step is "Start your OpenClaw agent"), which is why creation silently
9
+ // no-op'd on fresh installs without a running gateway. The bridge holds an
10
+ // authenticated gateway WS by construction, so the ensure runs there instead.
11
+ //
12
+ // Once-per-install semantics: a marker file records that the ensure has run to
13
+ // completion. Without it, a user who deliberately deleted their heartbeat cron
14
+ // would get it resurrected on every bridge boot — the old setup-time path ran
15
+ // once, and that operator-override behaviour must be preserved.
16
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
17
+ import { dirname } from 'path';
18
+ export const HEARTBEAT_CRON_NAME = 'reefclaw-heartbeat';
19
+ export const HEARTBEAT_EVERY_MS = 15 * 60 * 1000;
20
+ // Byte-identical to the message the setup-time CLI path created, so new jobs
21
+ // match every existing install's job.
22
+ export const HEARTBEAT_MESSAGE = 'Heartbeat. Run SESSION START mandatory checks, then the full Decision Loop (Steps 0-9) from SKILL.md. ' +
23
+ 'Check tradingMode from fetch_balance — it is your source of truth for paper vs live. ' +
24
+ 'Use tools in parallel where possible.';
25
+ /**
26
+ * Whether an existing cron job counts as "the heartbeat already exists".
27
+ * Union of the two matchers already in the codebase: the old setup path
28
+ * matched /reefclaw/, while resolveHeartbeatSeconds() (the cadence reader that
29
+ * drives the dashboard header) matches /heart\s*beat/. A job satisfying either
30
+ * must suppress creation — prod/wisekid boxes carry heartbeat jobs whose names
31
+ * predate the reefclaw- prefix, and a duplicate 15m heartbeat would double the
32
+ * agent's token burn.
33
+ */
34
+ export function isHeartbeatLikeName(name) {
35
+ return typeof name === 'string' && /reefclaw|heart\s*beat/i.test(name);
36
+ }
37
+ /**
38
+ * Ensure a heartbeat cron exists. Non-fatal by contract: every failure path
39
+ * returns 'failed' rather than throwing, and only success writes the marker so
40
+ * a transient gateway error retries on the next boot.
41
+ */
42
+ export async function ensureHeartbeatCron(opts) {
43
+ const { rpc, markerPath, log } = opts;
44
+ try {
45
+ if (existsSync(markerPath))
46
+ return 'already_ensured';
47
+ // includeDisabled: a job the user disabled still means "exists" — re-adding
48
+ // a duplicate next to a deliberately-disabled one is worse than doing nothing.
49
+ const listResp = (await rpc.sendRpc('cron.list', { includeDisabled: true }));
50
+ const jobs = Array.isArray(listResp?.jobs) ? listResp.jobs : null;
51
+ if (!jobs) {
52
+ log('Heartbeat cron: skipped (cron.list returned no job list)');
53
+ return 'failed';
54
+ }
55
+ const existing = jobs.find((j) => isHeartbeatLikeName(j?.name));
56
+ if (existing) {
57
+ writeMarker(markerPath, { ensuredAt: new Date().toISOString(), found: String(existing.name) });
58
+ log(`Heartbeat cron: already exists (${String(existing.name)})`);
59
+ return 'found_existing';
60
+ }
61
+ // Parity with the params the CLI built for the old setup command
62
+ // (`openclaw cron add --name reefclaw-heartbeat --every 15m --session isolated --message ...`):
63
+ // isolated agentTurn defaults to delivery announce on the 'last' channel.
64
+ await rpc.sendRpc('cron.add', {
65
+ name: HEARTBEAT_CRON_NAME,
66
+ enabled: true,
67
+ schedule: { kind: 'every', everyMs: HEARTBEAT_EVERY_MS },
68
+ sessionTarget: 'isolated',
69
+ wakeMode: 'now',
70
+ payload: { kind: 'agentTurn', message: HEARTBEAT_MESSAGE },
71
+ delivery: { mode: 'announce', channel: 'last' },
72
+ });
73
+ writeMarker(markerPath, { ensuredAt: new Date().toISOString(), created: HEARTBEAT_CRON_NAME });
74
+ log(`Heartbeat cron: created (${HEARTBEAT_CRON_NAME}, every 15m)`);
75
+ return 'created';
76
+ }
77
+ catch (err) {
78
+ log(`Heartbeat cron: skipped (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`);
79
+ return 'failed';
80
+ }
81
+ }
82
+ function writeMarker(markerPath, data) {
83
+ mkdirSync(dirname(markerPath), { recursive: true });
84
+ writeFileSync(markerPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
85
+ }
@@ -29,6 +29,8 @@ export declare class GatewayProvider implements OpenClawProvider {
29
29
  private agentIdentity;
30
30
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
31
31
  private loggedAgentIdentityProbe;
32
+ /** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
33
+ private heartbeatCronEnsureStarted;
32
34
  /** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
33
35
  private heartbeatSeconds?;
34
36
  private heartbeatReadAtMs;
@@ -11,16 +11,26 @@ 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, } from '../gateway/event-parser.js';
13
13
  import { Poller } from '../gateway/poller.js';
14
+ import { ensureHeartbeatCron } from '../gateway/heartbeat-cron.js';
14
15
  import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
15
16
  import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
16
17
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
17
18
  const TAG = 'gateway';
18
- // ---- Session NAV persistence ----
19
- // Persists sessionStartNav per date so Day P&L survives skill restarts.
20
- const SESSION_NAV_FILENAME = 'session-nav.json';
19
+ // ---- Day-start NAV persistence ----
20
+ // Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
21
+ // survives skill restarts. The file was historically named session-nav.json;
22
+ // renamed to day-start-nav.json 2026-07-22 — "session" was a misnomer (the
23
+ // value anchors the trading DAY, not any auth/session state) and tripped
24
+ // ClawHub's sensitive-file-read heuristic. Reads fall back to the legacy name
25
+ // once, writes go to the new name only.
26
+ const DAY_NAV_FILENAME = 'day-start-nav.json';
27
+ const LEGACY_DAY_NAV_FILENAME = 'session-nav.json';
21
28
  const TRADING_MODE_FILENAME = 'trading-mode.json';
22
- function getSessionNavPath() {
23
- return join(homedir(), '.openclaw', 'workspace', SESSION_NAV_FILENAME);
29
+ function getDayNavPath() {
30
+ return join(homedir(), '.openclaw', 'workspace', DAY_NAV_FILENAME);
31
+ }
32
+ function getLegacyDayNavPath() {
33
+ return join(homedir(), '.openclaw', 'workspace', LEGACY_DAY_NAV_FILENAME);
24
34
  }
25
35
  function getTradingModePath() {
26
36
  return join(homedir(), '.openclaw', 'workspace', TRADING_MODE_FILENAME);
@@ -40,17 +50,20 @@ export function shouldAnchorSessionNav(args) {
40
50
  return args.mode === 'PAPER' && args.sessionDate !== args.today; // paper midnight rollover
41
51
  }
42
52
  function loadSessionStartNav() {
43
- try {
44
- const raw = readFileSync(getSessionNavPath(), 'utf-8');
45
- const data = JSON.parse(raw);
46
- if (data.date === todayDateStr() && typeof data.sessionStartNav === 'number' && data.sessionStartNav > 0) {
47
- return data.sessionStartNav;
53
+ for (const path of [getDayNavPath(), getLegacyDayNavPath()]) {
54
+ try {
55
+ const raw = readFileSync(path, 'utf-8');
56
+ const data = JSON.parse(raw);
57
+ if (data.date === todayDateStr() && typeof data.sessionStartNav === 'number' && data.sessionStartNav > 0) {
58
+ return data.sessionStartNav;
59
+ }
60
+ return null; // Different day or invalid — don't let a stale legacy file shadow it
61
+ }
62
+ catch {
63
+ /* missing or corrupt — try the legacy name */
48
64
  }
49
- return null; // Different day or invalid
50
- }
51
- catch {
52
- return null; // File doesn't exist or is corrupt
53
65
  }
66
+ return null;
54
67
  }
55
68
  /**
56
69
  * Persisted across skill restarts so the FIRST snapshot the gateway emits
@@ -90,7 +103,11 @@ function saveSessionStartNav(nav) {
90
103
  const dir = join(homedir(), '.openclaw', 'workspace');
91
104
  mkdirSync(dir, { recursive: true });
92
105
  const data = { date: todayDateStr(), sessionStartNav: nav };
93
- writeFileSync(getSessionNavPath(), JSON.stringify(data, null, 2), 'utf-8');
106
+ writeFileSync(getDayNavPath(), JSON.stringify(data, null, 2), 'utf-8');
107
+ try {
108
+ unlinkSync(getLegacyDayNavPath());
109
+ }
110
+ catch { /* legacy file already gone */ }
94
111
  }
95
112
  catch (err) {
96
113
  logger.warn(TAG, `Failed to save session NAV: ${err instanceof Error ? err.message : String(err)}`);
@@ -122,6 +139,8 @@ export class GatewayProvider {
122
139
  agentIdentity = {};
123
140
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
124
141
  loggedAgentIdentityProbe = false;
142
+ /** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
143
+ heartbeatCronEnsureStarted = false;
125
144
  /** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
126
145
  heartbeatSeconds;
127
146
  heartbeatReadAtMs = 0;
@@ -1070,6 +1089,24 @@ export class GatewayProvider {
1070
1089
  // every (re)connect so a model swap — which restarts the gateway and drops
1071
1090
  // this socket — is reflected on the next handshake.
1072
1091
  void this.refreshAgentIdentity();
1092
+ // Ensure the heartbeat cron exists — via gateway RPC, once per install
1093
+ // (marker-file guarded; a deliberate operator delete is never resurrected).
1094
+ // Lives here instead of setup because setup runs before the gateway is up.
1095
+ if (!this.heartbeatCronEnsureStarted) {
1096
+ this.heartbeatCronEnsureStarted = true;
1097
+ const ws = this.wsClient;
1098
+ if (ws) {
1099
+ void ensureHeartbeatCron({
1100
+ rpc: ws,
1101
+ markerPath: join(homedir(), '.openclaw', 'workspace', 'heartbeat-cron-ensured.json'),
1102
+ log: (msg) => logger.info(TAG, msg),
1103
+ }).then((outcome) => {
1104
+ // A transient failure retries on the next (re)connect, not just next boot.
1105
+ if (outcome === 'failed')
1106
+ this.heartbeatCronEnsureStarted = false;
1107
+ });
1108
+ }
1109
+ }
1073
1110
  // Start periodic agent state emission (every 5s)
1074
1111
  if (!this.agentStateInterval) {
1075
1112
  this.agentStateInterval = setInterval(() => {
@@ -1589,7 +1626,11 @@ export class GatewayProvider {
1589
1626
  this.hasReceivedPositions = false;
1590
1627
  this.pendingEmptyPoll = false;
1591
1628
  try {
1592
- unlinkSync(getSessionNavPath());
1629
+ unlinkSync(getDayNavPath());
1630
+ }
1631
+ catch { /* ok if missing */ }
1632
+ try {
1633
+ unlinkSync(getLegacyDayNavPath());
1593
1634
  }
1594
1635
  catch { /* ok if missing */ }
1595
1636
  logger.info(TAG, `Session start NAV reset on mode change (${prevMode} → ${this.tradingMode})`);
package/bridge/setup.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Interactive setup helper for the ReefClaw skill.
2
2
  // Validates token format, tests relay connection, saves to OpenClaw config.
3
3
  import WebSocket from 'ws';
4
- import { execSync } from 'node:child_process';
5
4
  import { writeOpenClawConfig } from './config.js';
6
5
  import { logger } from './logger.js';
7
6
  const TAG = 'setup';
@@ -149,8 +148,12 @@ export async function runSetup(tokenOrBundle, userId, relayUrl) {
149
148
  }
150
149
  // Save to config
151
150
  writeOpenClawConfig(token, resolvedUserId, resolvedRelayUrl);
152
- // Create heartbeat cron job if none exists
153
- ensureHeartbeatCron();
151
+ // Heartbeat cron is ensured by the bridge at first gateway connect (via
152
+ // cron.list/cron.add RPC — see gateway/heartbeat-cron.ts). Setup can't do it:
153
+ // the gateway isn't running yet at this point, which is also why the old
154
+ // setup-time shell-out to the openclaw CLI silently no-op'd on fresh
155
+ // installs (the CLI's cron commands are gateway RPC clients too).
156
+ console.log(' Heartbeat cron: created automatically when the agent first connects (every 15m)');
154
157
  console.log('');
155
158
  console.log(' Setup complete! Token saved to ~/.openclaw/config.json');
156
159
  console.log('');
@@ -161,54 +164,6 @@ export async function runSetup(tokenOrBundle, userId, relayUrl) {
161
164
  console.log(' 4. Your agent checks the market every 15 minutes (adjustable via chat)');
162
165
  console.log('');
163
166
  }
164
- // ---- Heartbeat cron ----
165
- /**
166
- * Ensures a heartbeat cron job exists for ReefClaw.
167
- * Checks `openclaw cron list` for an existing job with "reefclaw" in the name.
168
- * If none found, creates one with 15m default interval.
169
- * Non-fatal — if openclaw CLI isn't available or cron fails, setup still succeeds.
170
- */
171
- function ensureHeartbeatCron() {
172
- try {
173
- // Check if a reefclaw cron already exists
174
- const listOutput = execSync('openclaw cron list --json 2>/dev/null', {
175
- encoding: 'utf-8',
176
- timeout: 15_000,
177
- });
178
- // Look for any job with "reefclaw" in the name
179
- try {
180
- const jobs = JSON.parse(listOutput);
181
- const existing = (Array.isArray(jobs) ? jobs : []).find((j) => j.name?.toLowerCase().includes('reefclaw'));
182
- if (existing) {
183
- console.log(` Heartbeat cron: already exists (${existing.name})`);
184
- return;
185
- }
186
- }
187
- catch {
188
- // JSON parse failed — maybe not JSON output, check raw text
189
- if (listOutput.toLowerCase().includes('reefclaw')) {
190
- console.log(' Heartbeat cron: already exists');
191
- return;
192
- }
193
- }
194
- // No existing job — create one
195
- console.log(' Creating heartbeat cron (every 15m)...');
196
- execSync('openclaw cron add --name reefclaw-heartbeat --every 15m --session isolated ' +
197
- "--message 'Heartbeat. Run SESSION START mandatory checks, then the full Decision Loop (Steps 0-9) from SKILL.md. Check tradingMode from fetch_balance — it is your source of truth for paper vs live. Use tools in parallel where possible.' 2>&1", { encoding: 'utf-8', timeout: 15_000 });
198
- console.log(' Heartbeat cron: created (every 15 minutes)');
199
- console.log(' Tip: Ask your agent "change heartbeat to 10 minutes" to adjust');
200
- }
201
- catch (err) {
202
- // Non-fatal — user can create cron manually or agent can create it on first session
203
- const msg = err instanceof Error ? err.message : String(err);
204
- if (msg.includes('not found') || msg.includes('ENOENT')) {
205
- console.log(' Heartbeat cron: skipped (openclaw CLI not in PATH)');
206
- }
207
- else {
208
- console.log(` Heartbeat cron: skipped (${msg.split('\n')[0]})`);
209
- }
210
- }
211
- }
212
167
  function printSetupInstructions() {
213
168
  console.log(' To connect your OpenClaw agent to ReefClaw:');
214
169
  console.log('');
package/index.js CHANGED
@@ -863,8 +863,8 @@ let pluginToolsFactory = null;
863
863
  let pluginToolNames = [];
864
864
  const paperTradingPlugin = {
865
865
  id: PLUGIN_ID,
866
- name: 'ReefClaw Paper Trading',
867
- description: 'Paper trading plugin with real Binance market data and simulated execution. No API keys required.',
866
+ name: 'ReefClaw Trading',
867
+ description: 'Supervised trading plugin for the ReefClaw dashboard: paper trading with real market data (no API keys required), and optional live trading on Binance or Hyperliquid behind explicit operator opt-in, exchange API credentials, and always-on protective stop brackets.',
868
868
  configSchema: {
869
869
  type: 'object',
870
870
  properties: {
@@ -1181,9 +1181,16 @@ const paperTradingPlugin = {
1181
1181
  // ingest credentials. See docs/APPROVAL_MODE_DESIGN.md §2 + §7.3.
1182
1182
  let proposalDecisionListener;
1183
1183
  if (tradingMode === 'MICRO_LIVE' || tradingMode === 'LIVE') {
1184
- if (!exchangeConfig) {
1184
+ // Per-VENUE credential presence (rehearsal find #4): this safety check
1185
+ // predated the venue seam and tested only the Binance shape, so a
1186
+ // PERSISTED HL MICRO_LIVE (tradingMode written by the runtime flip)
1187
+ // silently fell back to PAPER on every reboot — while the same config
1188
+ // flipped live fine at runtime. The per-venue gate above is the real
1189
+ // validation; this remains the defense-in-depth backstop.
1190
+ const liveCredsPresent = venue === 'hyperliquid' ? hlCredentials !== null : exchangeConfig !== null;
1191
+ if (!liveCredsPresent) {
1185
1192
  // Already handled above (falls back to PAPER), but safety check
1186
- logger.error(TAG, `${tradingMode} mode requires exchange config — this should not happen`);
1193
+ logger.error(TAG, `${tradingMode} mode requires ${venue} exchange credentials — this should not happen`);
1187
1194
  adapter = new PaperAdapter(simulator);
1188
1195
  tradingMode = 'PAPER';
1189
1196
  }
@@ -3,6 +3,8 @@ import type { ExchangeConfig, TradingMode } from '../types.js';
3
3
  import type { ExchangeSimulator } from '../simulator/exchange-simulator.js';
4
4
  import type { PaperMarketFeed } from '../simulator/paper-market-feed.js';
5
5
  import { LiveAdapter } from '../live/live-adapter.js';
6
+ import type { HlCredentials } from '../venues/hyperliquid/hl-private.js';
7
+ import { type VenueId } from '../venues/registry.js';
6
8
  import { PositionWatcher } from '../live/stop-watcher.js';
7
9
  import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
8
10
  export interface MicroLiveConfig {
@@ -14,6 +16,14 @@ export interface BuildAdapterInput {
14
16
  exchange: ExchangeConfig | null;
15
17
  microLive?: MicroLiveConfig;
16
18
  simulator: ExchangeSimulator;
19
+ /** Venue seam for the RUNTIME transition path (issue #217). Absent →
20
+ * 'binance' (every pre-venue caller byte-identical). The BOOT path got
21
+ * this seam in Phase 3 (createLiveAdapter in index.ts); the runtime flip
22
+ * — set_trading_mode, the product's go-live moment — missed it and
23
+ * constructed a Binance LiveAdapter unconditionally. */
24
+ venue?: VenueId;
25
+ /** Required for a live-mode build on the hyperliquid venue. */
26
+ hlCredentials?: HlCredentials | null;
17
27
  }
18
28
  /** Wave 9 safety wiring is created only after its durable ledger is loaded.
19
29
  * Runtime reapplies these hooks to both the bootstrap objects and every
@@ -81,6 +91,8 @@ export declare class PluginRuntime {
81
91
  mode: TradingMode;
82
92
  exchange: ExchangeConfig | null;
83
93
  microLive?: MicroLiveConfig;
94
+ venue?: VenueId;
95
+ hlCredentials?: HlCredentials | null;
84
96
  }, deps: {
85
97
  adapterDeps: {
86
98
  adapter: IExchangeAdapter;
@@ -13,9 +13,11 @@
13
13
  // instant — no gateway restart required.
14
14
  import { PaperAdapter } from '../paper-adapter.js';
15
15
  import { LiveAdapter } from '../live/live-adapter.js';
16
+ import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
17
+ import { createLiveAdapter } from '../venues/registry.js';
16
18
  import { PositionWatcher } from '../live/stop-watcher.js';
17
19
  import { loadBracketMode } from '../config/brackets-config.js';
18
- import { loadStopWatcherIntervalMs } from '../config/plugin-config-io.js';
20
+ import { loadStopWatcherIntervalMs, readPluginConfig } from '../config/plugin-config-io.js';
19
21
  import { logger, formatError } from '../logger.js';
20
22
  const TAG = 'plugin-runtime';
21
23
  /** Pure-ish factory: builds an adapter for the requested mode.
@@ -29,6 +31,23 @@ export function buildAdapter(input) {
29
31
  // ShadowTracker wraps a BinancePrivateApi for real-balance comparison.
30
32
  return new PaperAdapter(simulator);
31
33
  }
34
+ // Hyperliquid live (issue #217): mirror the boot path's construction —
35
+ // per-venue credential shape, the SAME factory (createLiveAdapter), and
36
+ // the same fall-back-to-paper defense when credentials are absent.
37
+ if (input.venue === 'hyperliquid') {
38
+ if (!input.hlCredentials) {
39
+ logger.warn(TAG, `${mode} on hyperliquid requested without walletAddress+agentPrivateKey — falling back to PAPER`);
40
+ return new PaperAdapter(simulator);
41
+ }
42
+ return createLiveAdapter({
43
+ venue: 'hyperliquid',
44
+ args: {
45
+ credentials: input.hlCredentials,
46
+ mode: mode,
47
+ marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
48
+ },
49
+ });
50
+ }
32
51
  if (!exchange) {
33
52
  logger.warn(TAG, `${mode} requested without credentials — falling back to PAPER`);
34
53
  return new PaperAdapter(simulator);
@@ -131,12 +150,24 @@ export class PluginRuntime {
131
150
  logger.warn(TAG, `old adapter shutdown failed: ${formatError(err)}`);
132
151
  }
133
152
  }
153
+ else if (old instanceof HyperliquidLiveAdapter) {
154
+ // Stops the HL user stream + the 60s REST truth-check timer — without
155
+ // this, a flip AWAY from HL live leaks both (issue #217).
156
+ try {
157
+ old.stop();
158
+ }
159
+ catch (err) {
160
+ logger.warn(TAG, `old HL adapter stop failed: ${formatError(err)}`);
161
+ }
162
+ }
134
163
  // 3. Build the new adapter.
135
164
  const fresh = buildAdapter({
136
165
  mode: next.mode,
137
166
  exchange: next.exchange,
138
167
  microLive: next.microLive,
139
168
  simulator: this.simulator,
169
+ venue: next.venue,
170
+ hlCredentials: next.hlCredentials,
140
171
  });
141
172
  // Install autonomous protection callbacks before initialization can emit
142
173
  // user-data or bracket-reconciler events.
@@ -145,7 +176,7 @@ export class PluginRuntime {
145
176
  }
146
177
  // 4. Fire async init for live adapters (non-blocking — readiness flips
147
178
  // INIT_PENDING → READY/DEGRADED/BLOCKED on its own).
148
- if (fresh instanceof LiveAdapter) {
179
+ if (fresh instanceof LiveAdapter || fresh instanceof HyperliquidLiveAdapter) {
149
180
  fresh.initialize().catch(err => {
150
181
  logger.error(TAG, `New adapter init failed: ${formatError(err)}`);
151
182
  });
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
- "name": "ReefClaw Paper Trading",
4
- "version": "0.1.0",
5
- "description": "Paper trading plugin with real Binance market data and simulated execution. No API keys required.",
3
+ "name": "ReefClaw Trading",
4
+ "version": "0.1.9",
5
+ "description": "Supervised trading plugin for the ReefClaw dashboard: paper trading with real market data (no API keys required), and optional live trading on Binance or Hyperliquid behind explicit operator opt-in, exchange API credentials, and always-on protective stop brackets. Includes the dashboard connector bridge, heartbeat automation, and remote SKILL.md instruction updates from the ReefClaw webapp.",
6
6
  "author": "ReefClaw",
7
7
  "activation": {
8
8
  "onStartup": true
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.7",
4
- "description": "ReefClaw trading plugin for OpenClaw \u2014 paper trading with real Binance market data, plus the ReefClaw dashboard connector (supervised by OpenClaw, no service manager needed). Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
3
+ "version": "0.1.9",
4
+ "description": "ReefClaw supervised trading plugin for OpenClaw \u2014 paper trading with real market data, optional live trading on Binance or Hyperliquid (operator opt-in, API keys, always-on protective brackets), plus the ReefClaw dashboard connector with heartbeat automation and remote SKILL.md updates from the ReefClaw webapp. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "openclaw": {
@@ -66,16 +66,32 @@ if (!existsSync(pluginDist)) {
66
66
  console.log('[assemble] plugin/dist -> package root');
67
67
  }
68
68
 
69
- // 2. Manifest, with the `skills` declaration injected (npm package only).
69
+ // 2. Manifest, with the `skills` declaration injected (npm package only) and the
70
+ // release version stamped from THIS package's package.json.
71
+ //
72
+ // package.json#version is the authoritative release version — that is what
73
+ // ClawHub's `package-manifest-version-drift` rule compares against, and what
74
+ // OpenClaw's own `openclaw plugins build` writes into the manifest. The repo
75
+ // manifest (plugin/openclaw.plugin.json) carries a workspace-local version
76
+ // nobody bumps at release time, so copying it verbatim published
77
+ // manifest:0.1.0 against package:0.1.7 and tripped the validator. Deriving it
78
+ // here makes that drift structurally impossible instead of a bump to remember.
70
79
  const manifestSrc = join(repoRoot, 'plugin', 'openclaw.plugin.json');
80
+ const pkgVersion = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
71
81
  if (!existsSync(manifestSrc)) {
72
82
  console.error('[assemble] missing plugin/openclaw.plugin.json.');
73
83
  missing += 1;
84
+ } else if (typeof pkgVersion !== 'string' || pkgVersion.length === 0) {
85
+ console.error('[assemble] package.json has no version — cannot stamp the manifest.');
86
+ missing += 1;
74
87
  } else {
75
88
  const manifest = JSON.parse(readFileSync(manifestSrc, 'utf-8'));
89
+ manifest.version = pkgVersion;
76
90
  manifest.skills = ['./skills'];
77
91
  writeFileSync(join(pkgRoot, 'openclaw.plugin.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
78
- console.log(`[assemble] manifest (+skills decl), ${manifest.contracts?.tools?.length ?? 0} contract tools`);
92
+ console.log(
93
+ `[assemble] manifest v${pkgVersion} (+skills decl), ${manifest.contracts?.tools?.length ?? 0} contract tools`,
94
+ );
79
95
  }
80
96
 
81
97
  // 3. The connector, bundled — this is what flips the supervisor's auto-start.
@@ -10,6 +10,13 @@ import { type Wave9LiveSymbolOwnershipCheck } from '../wave9/live-symbol-ownersh
10
10
  import { type Wave9LiveResidualProtectionResult } from '../wave9/live-residual-protection.js';
11
11
  import { type Wave9PaperAdmissionGuard } from '../wave9/paper-admission-guard.js';
12
12
  type Wave9LiveResidualProtector = (adapter: IExchangeAdapter, ledger: Wave9LiveExecutionLedger, candidateId: string, symbol: string) => Promise<Wave9LiveResidualProtectionResult>;
13
+ /** Venue-aware idempotency cid for the live entry stash. HL client-order-ids
14
+ * are 128-bit hex (0x + 32 hex chars) — a dashed randomUUID() fails
15
+ * hl-private's cloid validation and the ENTRY IS REJECTED before reaching
16
+ * the exchange (found live by the go-live rehearsal's first dust trade).
17
+ * buildHlOrderCloid() mints the 0x0d… ORDER prefix, which the bracket parser
18
+ * deliberately never recognises (cross-scheme cancels are destructive). */
19
+ export declare function mintEntryStashCid(adapter: IExchangeAdapter): string;
13
20
  export declare function createOrderTool(args: {
14
21
  symbol: string;
15
22
  side: string;
@@ -11,6 +11,8 @@ import { onCreateOrderFilled } from '../ingest/position-auto-capture.js';
11
11
  import { wave9ClientOrderId, } from '../wave9/live-execution-ledger.js';
12
12
  import { inspectWave9LiveSymbolOwnership, } from '../wave9/live-symbol-ownership.js';
13
13
  import { confirmWave9LivePositionFlat } from '../wave9/live-position-confirmation.js';
14
+ import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
15
+ import { buildHlOrderCloid } from '../venues/hyperliquid/hl-cloid.js';
14
16
  import { reprotectWave9LiveResidual, } from '../wave9/live-residual-protection.js';
15
17
  import { captureWave9PaperAccountSnapshot, isWave9ManagedPosition, WAVE9_BUNDLE_SETUP_TYPE, } from '../wave9/paper-admission-guard.js';
16
18
  function currentWave9ExecutionMode(deps) {
@@ -231,6 +233,15 @@ function candidateBoundFoundOrder(resolution, clientOrderId, symbol, side) {
231
233
  return undefined;
232
234
  return order;
233
235
  }
236
+ /** Venue-aware idempotency cid for the live entry stash. HL client-order-ids
237
+ * are 128-bit hex (0x + 32 hex chars) — a dashed randomUUID() fails
238
+ * hl-private's cloid validation and the ENTRY IS REJECTED before reaching
239
+ * the exchange (found live by the go-live rehearsal's first dust trade).
240
+ * buildHlOrderCloid() mints the 0x0d… ORDER prefix, which the bracket parser
241
+ * deliberately never recognises (cross-scheme cancels are destructive). */
242
+ export function mintEntryStashCid(adapter) {
243
+ return adapter instanceof HyperliquidLiveAdapter ? buildHlOrderCloid() : randomUUID();
244
+ }
234
245
  async function resolveWave9EntryCid(adapter, clientOrderId, symbol) {
235
246
  if (!adapter.resolveOrderByClientId) {
236
247
  return { status: 'unknown', detail: 'deterministic client-order resolver is unavailable' };
@@ -1133,7 +1144,7 @@ export async function createOrderTool(args, deps) {
1133
1144
  ? wave9ClientOrderId(args.candidate_id)
1134
1145
  : undefined;
1135
1146
  if (deps.autoCapture?.pendingEntries && metadata) {
1136
- stashCid ??= randomUUID();
1147
+ stashCid ??= mintEntryStashCid(deps.adapter);
1137
1148
  deps.autoCapture.pendingEntries.put({
1138
1149
  orderId: stashCid,
1139
1150
  clientOrderId: stashCid,
@@ -9,6 +9,7 @@
9
9
  // because the caller chain is currently controlled end-to-end by the skill.
10
10
  import { readPluginConfig } from '../config/plugin-config-io.js';
11
11
  import { validateModeTransition, modeRequiresCredentials } from '../onboarding/mode-ladder.js';
12
+ import { parseVenue } from '../venues/registry.js';
12
13
  import { logger } from '../logger.js';
13
14
  import { recordModeTransition } from '../audit/mode-transition-audit.js';
14
15
  const TAG = 'set-trading-mode';
@@ -49,12 +50,26 @@ export async function setTradingModeTool(args, deps) {
49
50
  readiness: deps.runtime.adapter.readiness,
50
51
  };
51
52
  }
52
- // 3. Credential requirement check.
53
+ // 3. Credential requirement check — PER VENUE (issue #217: this gate was
54
+ // Binance-only, so the go-live flip on the hyperliquid venue was rejected
55
+ // despite valid walletAddress+agentPrivateKey in plugin-config).
53
56
  let exchange = null;
57
+ let hlCredentials = null;
58
+ let venue = 'binance';
54
59
  if (modeRequiresCredentials(target)) {
55
60
  try {
56
61
  const cfg = readPluginConfig(deps.configPath);
57
- if (cfg.exchange?.apiKey && cfg.exchange?.secret) {
62
+ venue = parseVenue(cfg.exchange?.venue).venue;
63
+ if (venue === 'hyperliquid') {
64
+ if (cfg.exchange?.walletAddress && cfg.exchange?.agentPrivateKey) {
65
+ hlCredentials = {
66
+ walletAddress: String(cfg.exchange.walletAddress),
67
+ agentPrivateKey: String(cfg.exchange.agentPrivateKey),
68
+ testnet: cfg.exchange.testnet === true,
69
+ };
70
+ }
71
+ }
72
+ else if (cfg.exchange?.apiKey && cfg.exchange?.secret) {
58
73
  exchange = {
59
74
  apiKey: cfg.exchange.apiKey,
60
75
  secret: cfg.exchange.secret,
@@ -74,11 +89,13 @@ export async function setTradingModeTool(args, deps) {
74
89
  reason: 'config_read_error',
75
90
  };
76
91
  }
77
- if (!exchange) {
92
+ if (!exchange && !hlCredentials) {
78
93
  recordModeTransition({ previousMode, targetMode: target, acknowledged, ok: false, reason: 'missing_credentials' });
79
94
  return {
80
95
  ok: false,
81
- message: `${target} mode requires exchange credentials. Run set_exchange_credentials first.`,
96
+ message: venue === 'hyperliquid'
97
+ ? `${target} mode on Hyperliquid requires exchange.walletAddress (master) + exchange.agentPrivateKey (agent wallet) in plugin-config.`
98
+ : `${target} mode requires exchange credentials. Run set_exchange_credentials first.`,
82
99
  previousMode,
83
100
  mode: previousMode,
84
101
  readiness: deps.runtime.adapter.readiness,
@@ -116,14 +133,14 @@ export async function setTradingModeTool(args, deps) {
116
133
  };
117
134
  }
118
135
  // 6. Swap the adapter.
119
- await deps.runtime.reconnect({ mode: target, exchange }, { adapterDeps: deps.adapterDeps });
136
+ await deps.runtime.reconnect({ mode: target, exchange, venue, hlCredentials }, { adapterDeps: deps.adapterDeps });
120
137
  logger.info(TAG, `Transitioned ${previousMode} → ${target}`);
121
138
  recordModeTransition({
122
139
  previousMode,
123
140
  targetMode: target,
124
141
  acknowledged,
125
142
  ok: true,
126
- testnet: exchange?.testnet,
143
+ testnet: exchange?.testnet ?? hlCredentials?.testnet,
127
144
  });
128
145
  return {
129
146
  ok: true,
package/types.js CHANGED
@@ -1,4 +1,4 @@
1
- // Types for the ReefClaw Paper Trading plugin.
1
+ // Types for the ReefClaw Trading plugin.
2
2
  // CCXT-compatible types that match what the GatewayProvider's event-parser expects.
3
3
  export const DEFAULT_CONFIG = {
4
4
  startingBalance: 10000,