@reefclaw/openclaw-plugin 0.1.14 → 0.1.16

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 (38) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  5. package/bridge/gateway/gateway-ws-client.js +41 -11
  6. package/bridge/providers/gateway.d.ts +28 -0
  7. package/bridge/providers/gateway.js +130 -5
  8. package/bridge/providers/onboarding-commands.d.ts +8 -5
  9. package/bridge/providers/onboarding-commands.js +1 -1
  10. package/bridge/providers/risk-calculator.d.ts +61 -2
  11. package/bridge/providers/risk-calculator.js +92 -20
  12. package/bridge/utils/skill-signing.js +8 -3
  13. package/config/plugin-config-io.js +5 -0
  14. package/exchange-adapter.d.ts +13 -0
  15. package/index.js +15 -4
  16. package/ingest/event-loop-monitor.d.ts +11 -0
  17. package/ingest/event-loop-monitor.js +77 -0
  18. package/ingest/readiness-reporter.d.ts +9 -0
  19. package/ingest/readiness-reporter.js +54 -5
  20. package/live/user-data-stream.js +10 -2
  21. package/openclaw.plugin.json +1 -1
  22. package/package.json +9 -6
  23. package/risk/pre-trade-check.js +18 -5
  24. package/skills/reefclaw/SKILL.md +6 -11
  25. package/strategy/condition-registry.js +9 -2
  26. package/strategy/evaluator.d.ts +5 -0
  27. package/tools/cancel-all-orders.js +9 -1
  28. package/tools/create-order.js +18 -1
  29. package/tools/get-bracket-config.d.ts +21 -2
  30. package/tools/get-bracket-config.js +18 -2
  31. package/tools/set-trading-mode.js +6 -3
  32. package/venues/hyperliquid/hl-fill-ingest.js +20 -1
  33. package/venues/hyperliquid/hl-live-adapter.d.ts +4 -0
  34. package/venues/hyperliquid/hl-live-adapter.js +4 -0
  35. package/venues/registry.js +8 -7
  36. package/wave9/paper-admission-guard.d.ts +12 -1
  37. package/wave9/paper-admission-guard.js +12 -1
  38. package/scripts/assemble.mjs +0 -130
@@ -43,11 +43,62 @@ export function applyRegimeAdjustment(baseLimits, regime) {
43
43
  };
44
44
  }
45
45
  // ---- Drawdown zone thresholds ----
46
+ /** Fallback zone boundaries, used only until the tenant's trading params load.
47
+ * The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
48
+ * trading params — the same numbers the plugin's pre-trade gate rejects on.
49
+ * Keeping a second hardcoded ladder here is how the skill ended up
50
+ * auto-flattening at -2.5% on an account configured to -4%. */
46
51
  export const DRAWDOWN_ZONE_THRESHOLDS = {
47
52
  YELLOW: -0.01, // -1%
48
53
  ORANGE: -0.02, // -2%
49
54
  RED: -0.025, // -2.5%
50
55
  };
56
+ // ---- Hardcoded bounds on tenant-supplied risk params ----
57
+ //
58
+ // CLAUDE.md load-bearing rule: central config may move knobs only WITHIN
59
+ // hardcoded bounds — it must never be able to disable a safety mechanism.
60
+ // The RED threshold drives the auto-flatten, so without a floor a single
61
+ // careless dashboard edit (drawdownRed = -0.5) would silently switch the
62
+ // auto-flatten off. Out-of-band values are REJECTED (keep last-good), not
63
+ // clamped — we never invent a threshold the operator didn't set.
64
+ /** The auto-flatten trigger can never be configured looser than −10%. */
65
+ export const AUTO_FLATTEN_RED_FLOOR = -0.10;
66
+ /** Sanity bands for tenant limit fields. Values outside → field ignored,
67
+ * previous value kept. Wide on purpose: these reject nonsense, not policy. */
68
+ export const TENANT_LIMIT_BOUNDS = {
69
+ maxPositionSize: { min: 1, max: 1e9 },
70
+ maxOpenPositions: { min: 1, max: 100 },
71
+ maxGrossExposure: { min: 0.01, max: 20 },
72
+ maxPerTradeLoss: { min: 0.01, max: 1e7 }, // magnitude; sign applied by caller
73
+ };
74
+ /** Finite number within [min, max], else null. */
75
+ export function boundedNum(v, min, max) {
76
+ if (typeof v !== 'number' || !Number.isFinite(v))
77
+ return null;
78
+ return v >= min && v <= max ? v : null;
79
+ }
80
+ /**
81
+ * Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
82
+ * a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
83
+ * losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
84
+ * put the book straight into RED and auto-flatten it); RED no looser than
85
+ * AUTO_FLATTEN_RED_FLOOR.
86
+ */
87
+ export function validateDrawdownLadder(t) {
88
+ for (const [k, v] of Object.entries(t)) {
89
+ if (typeof v !== 'number' || !Number.isFinite(v))
90
+ return `${k} is not a finite number`;
91
+ }
92
+ if (t.YELLOW > 0)
93
+ return `YELLOW ${t.YELLOW} must be ≤ 0`;
94
+ if (!(t.YELLOW > t.ORANGE && t.ORANGE > t.RED)) {
95
+ return `not monotonic (need YELLOW > ORANGE > RED, got ${t.YELLOW} / ${t.ORANGE} / ${t.RED})`;
96
+ }
97
+ if (t.RED < AUTO_FLATTEN_RED_FLOOR) {
98
+ return `RED ${t.RED} is looser than the hardcoded auto-flatten floor ${AUTO_FLATTEN_RED_FLOOR}`;
99
+ }
100
+ return null;
101
+ }
51
102
  // ---- Rate tracking window ----
52
103
  const RATE_WINDOW_MS = 60_000; // 1 minute
53
104
  // ---- Volatility + drawdown pure functions ----
@@ -73,12 +124,12 @@ export function applyVolatilityAdjustment(baseLimits, volFactor) {
73
124
  };
74
125
  }
75
126
  /** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
76
- export function getDrawdownZone(drawdownRatio) {
77
- if (drawdownRatio <= DRAWDOWN_ZONE_THRESHOLDS.RED)
127
+ export function getDrawdownZone(drawdownRatio, thresholds = DRAWDOWN_ZONE_THRESHOLDS) {
128
+ if (drawdownRatio <= thresholds.RED)
78
129
  return 'RED';
79
- if (drawdownRatio <= DRAWDOWN_ZONE_THRESHOLDS.ORANGE)
130
+ if (drawdownRatio <= thresholds.ORANGE)
80
131
  return 'ORANGE';
81
- if (drawdownRatio <= DRAWDOWN_ZONE_THRESHOLDS.YELLOW)
132
+ if (drawdownRatio <= thresholds.YELLOW)
82
133
  return 'YELLOW';
83
134
  return 'GREEN';
84
135
  }
@@ -143,10 +194,22 @@ export function countRecentEvents(timestamps) {
143
194
  }
144
195
  /**
145
196
  * Detect risk limit breaches from current metrics.
197
+ *
198
+ * ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
199
+ * tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
200
+ * orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
201
+ * the dashboard must mean "the agent is being blocked right now".
202
+ *
203
+ * `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
204
+ * live only in this file — no enforcer applies them — so they may raise a
205
+ * WARNING (amber, indication) but must never produce a CRITICAL. Conflating
206
+ * the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
207
+ * live book whose 1.5x enforced limit was never even approached.
146
208
  */
147
- export function detectBreaches(metrics, limits) {
209
+ export function detectBreaches(metrics, limits, advisoryLimits) {
148
210
  const breaches = [];
149
211
  const now = new Date().toISOString();
212
+ const advisory = advisoryLimits ?? limits;
150
213
  // Gross exposure
151
214
  if (metrics.grossExposure > limits.position.maxGrossExposure) {
152
215
  breaches.push({
@@ -158,10 +221,11 @@ export function detectBreaches(metrics, limits) {
158
221
  timestamp: now,
159
222
  });
160
223
  }
161
- else if (metrics.grossExposure > limits.position.maxGrossExposure * 0.8) {
224
+ else if (metrics.grossExposure > advisory.position.maxGrossExposure ||
225
+ metrics.grossExposure > limits.position.maxGrossExposure * 0.8) {
162
226
  breaches.push({
163
227
  metric: 'grossExposure',
164
- limit: limits.position.maxGrossExposure,
228
+ limit: Math.min(advisory.position.maxGrossExposure, limits.position.maxGrossExposure),
165
229
  current: metrics.grossExposure,
166
230
  level: 'WARNING',
167
231
  action: 'ALERT',
@@ -179,10 +243,11 @@ export function detectBreaches(metrics, limits) {
179
243
  timestamp: now,
180
244
  });
181
245
  }
182
- else if (Math.abs(metrics.netExposure) > limits.position.maxNetExposure * 0.8) {
246
+ else if (Math.abs(metrics.netExposure) > advisory.position.maxNetExposure ||
247
+ Math.abs(metrics.netExposure) > limits.position.maxNetExposure * 0.8) {
183
248
  breaches.push({
184
249
  metric: 'netExposure',
185
- limit: limits.position.maxNetExposure,
250
+ limit: Math.min(advisory.position.maxNetExposure, limits.position.maxNetExposure),
186
251
  current: metrics.netExposure,
187
252
  level: 'WARNING',
188
253
  action: 'ALERT',
@@ -270,8 +335,12 @@ export function computeRiskMetrics(state) {
270
335
  // Prune and count rate events (non-mutating)
271
336
  const orderResult = countRecentEvents(state.orderTimestamps);
272
337
  const cancelResult = countRecentEvents(state.cancelTimestamps);
338
+ // Enforced limits: the tenant's configured trading params when they've been
339
+ // fetched, else the fallback constants. These are what the plugin's
340
+ // pre-trade gate rejects on, so they — and only they — drive CRITICAL.
341
+ const enforcedLimits = state.baseLimits ?? DEFAULT_RISK_LIMITS;
273
342
  // Volatility adjustment (when ATR data is available)
274
- let adjustedLimits = DEFAULT_RISK_LIMITS;
343
+ let adjustedLimits = enforcedLimits;
275
344
  let effectiveLimits;
276
345
  let volatilityInfo;
277
346
  let volFactor = 1.0;
@@ -279,7 +348,7 @@ export function computeRiskMetrics(state) {
279
348
  volFactor = computeVolFactor(state.atrData);
280
349
  const adjusted = volFactor > 1.0;
281
350
  if (adjusted) {
282
- adjustedLimits = applyVolatilityAdjustment(DEFAULT_RISK_LIMITS, volFactor);
351
+ adjustedLimits = applyVolatilityAdjustment(enforcedLimits, volFactor);
283
352
  }
284
353
  volatilityInfo = {
285
354
  atr14: state.atrData.currentAtr,
@@ -305,14 +374,17 @@ export function computeRiskMetrics(state) {
305
374
  };
306
375
  }
307
376
  }
308
- if (adjustedLimits !== DEFAULT_RISK_LIMITS) {
377
+ if (adjustedLimits !== enforcedLimits) {
309
378
  effectiveLimits = adjustedLimits;
310
379
  }
311
- // Drawdown zone
312
- const drawdownZone = getDrawdownZone(dailyDrawdown);
380
+ // Drawdown zone — boundaries come from the tenant's trading params so the
381
+ // skill's RED-zone auto-flatten fires at the operator's configured level,
382
+ // not a second hardcoded ladder that can be stricter than the plugin's.
383
+ const zoneThresholds = state.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
384
+ const drawdownZone = getDrawdownZone(dailyDrawdown, zoneThresholds);
313
385
  const drawdownZoneMessage = getDrawdownZoneMessage(drawdownZone, dailyDrawdown);
314
- // Detect breaches using effective limits (vol + regime adjusted) when available
315
- const limitsForBreaches = effectiveLimits ?? DEFAULT_RISK_LIMITS;
386
+ // Breaches: CRITICAL against the ENFORCED limits, WARNING against the
387
+ // vol/regime-tightened advisory view (see detectBreaches).
316
388
  const dailyLoss = state.realizedPnlToday ?? 0;
317
389
  const breaches = detectBreaches({
318
390
  grossExposure: grossRatio,
@@ -321,12 +393,12 @@ export function computeRiskMetrics(state) {
321
393
  dailyLoss,
322
394
  ordersPerMinute: orderResult.count,
323
395
  cancelsPerMinute: cancelResult.count,
324
- }, limitsForBreaches);
396
+ }, enforcedLimits, effectiveLimits);
325
397
  // Add zone-specific breaches
326
398
  if (drawdownZone === 'ORANGE' || drawdownZone === 'RED') {
327
399
  breaches.push({
328
400
  metric: 'drawdownZone',
329
- limit: drawdownZone === 'RED' ? DRAWDOWN_ZONE_THRESHOLDS.RED : DRAWDOWN_ZONE_THRESHOLDS.ORANGE,
401
+ limit: drawdownZone === 'RED' ? zoneThresholds.RED : zoneThresholds.ORANGE,
330
402
  current: dailyDrawdown,
331
403
  level: 'CRITICAL',
332
404
  action: drawdownZone === 'RED' ? 'AUTO_PAUSE' : 'REJECT_ORDER',
@@ -336,7 +408,7 @@ export function computeRiskMetrics(state) {
336
408
  else if (drawdownZone === 'YELLOW') {
337
409
  breaches.push({
338
410
  metric: 'drawdownZone',
339
- limit: DRAWDOWN_ZONE_THRESHOLDS.YELLOW,
411
+ limit: zoneThresholds.YELLOW,
340
412
  current: dailyDrawdown,
341
413
  level: 'WARNING',
342
414
  action: 'ALERT',
@@ -345,7 +417,7 @@ export function computeRiskMetrics(state) {
345
417
  }
346
418
  return {
347
419
  metrics: {
348
- limits: DEFAULT_RISK_LIMITS,
420
+ limits: enforcedLimits,
349
421
  utilization: {
350
422
  currentPositionSize: positionSizes,
351
423
  openPositionCount: state.positions.filter((p) => computePositionNotional(p, state.lastTickerPrice) !== 0).length,
@@ -14,9 +14,14 @@
14
14
  // (gitignored — keep it offline; NEVER on the relay or any server)
15
15
  // 4. rebuild + redeploy the skill (deploy-skillmd-ota.py is already wired to sign)
16
16
  //
17
- // Until a key is pinned, OTA is FAIL-CLOSED (every push rejected) while
18
- // SKILL_OTA_REQUIRE_SIGNATURE is on (the default). Normal trading is unaffected
19
- // — only the OTA-update path gates on this.
17
+ // ENFORCEMENT IS CURRENTLY DORMANT: SKILL_OTA_REQUIRE_SIGNATURE defaults OFF
18
+ // and no key is pinned (C1 is ON HOLD pending the agent-update-mechanism
19
+ // decision see signatureRequired() below). While dormant, OTA updates apply
20
+ // unsigned exactly as before this module existed; the authenticated webapp
21
+ // pull channel (rc_ token) remains the trust boundary. When the flag is turned
22
+ // ON: a pinned key verifies every update, and flag-on-without-a-key FAILS
23
+ // CLOSED (every push rejected). Normal trading is unaffected either way —
24
+ // only the OTA-update path gates on this.
20
25
  import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
21
26
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
22
27
  import { join } from 'node:path';
@@ -5,6 +5,11 @@
5
5
  // sets credentials or switches trading mode from the webapp. Writes must be
6
6
  // crash-safe (no half-written JSON) and must preserve keys we don't understand
7
7
  // (forwards compatibility — other tools may add fields we don't know about).
8
+ //
9
+ // Write authorization: every mutation path into this module is an
10
+ // operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
11
+ // audit F12) — the agent cannot reach these writes conversationally. The file
12
+ // is the plugin's OWN config store; nothing here touches OpenClaw's config.
8
13
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
9
14
  import { homedir } from 'node:os';
10
15
  import { dirname, join } from 'node:path';
@@ -28,6 +28,19 @@ export interface OrderOptions {
28
28
  * Only create_order is blocked when readiness !== 'READY'.
29
29
  */
30
30
  export interface IExchangeAdapter {
31
+ /**
32
+ * ★ Venue capability: this adapter's exchange-side protective legs ARE the
33
+ * safety floor — there is no watcher fallback and no `brackets.mode=off`
34
+ * arm, so brackets are attached unconditionally (Hyperliquid live).
35
+ *
36
+ * Read it wherever `brackets.mode` is consulted. That flag is a
37
+ * BINANCE-ONLY lifecycle knob (it is only ever passed to `LiveAdapter`), so
38
+ * treating it as the global answer made an HL rig report "Brackets off" on
39
+ * the dashboard AND — worse — skipped the mandatory-stop pre-trade gate,
40
+ * because that gate was wired behind `bracketsEnabled(loadBracketMode())`.
41
+ * Omitted/false on Binance + paper: byte-identical to the old behaviour.
42
+ */
43
+ readonly bracketsAlwaysEnforced?: boolean;
31
44
  createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, options?: OrderOptions): Promise<CcxtOrder>;
32
45
  cancelOrder(orderId: string, symbol?: string): Promise<CcxtOrder>;
33
46
  cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;
package/index.js CHANGED
@@ -816,6 +816,7 @@ const TOOL_PARAMS = {
816
816
  test_exchange_credentials: {
817
817
  type: 'object',
818
818
  properties: {
819
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
819
820
  apiKey: { type: 'string', description: 'Binance API key to verify (not persisted)' },
820
821
  secret: { type: 'string', description: 'Binance API secret to verify (not persisted)' },
821
822
  testnet: { type: 'boolean', description: 'Test against Binance testnet' },
@@ -2650,7 +2651,7 @@ const paperTradingPlugin = {
2650
2651
  {
2651
2652
  name: 'set_trading_mode',
2652
2653
  label: 'Set Trading Mode',
2653
- description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Refused without dashboard operator provenance.',
2654
+ description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Briefly reconnects the trading adapter (open positions keep their exchange-side protective brackets throughout). Refused without dashboard operator provenance.',
2654
2655
  parameters: TOOL_PARAMS.set_trading_mode,
2655
2656
  execute: async (_id, params) => {
2656
2657
  const prov = verifyOperatorProvenance(params.operator_token);
@@ -2662,9 +2663,17 @@ const paperTradingPlugin = {
2662
2663
  {
2663
2664
  name: 'test_exchange_credentials',
2664
2665
  label: 'Test Exchange Credentials',
2665
- description: 'Operator-only. Verify a Binance API key + secret with a read-only call (fetchBalance) without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials. Never called by the agent.',
2666
+ description: 'Operator-only. Verify a Binance API key + secret with a read-only call (fetchBalance) without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials. Refused without dashboard operator provenance.',
2666
2667
  parameters: TOOL_PARAMS.test_exchange_credentials,
2667
- execute: async (_id, params) => jsonResult(await testExchangeCredentialsTool(params)),
2668
+ // Read-only, but provenance-gated anyway: it makes a live authenticated
2669
+ // exchange call with caller-supplied keys, which would otherwise hand a
2670
+ // prompt-injected agent a credential-validation oracle.
2671
+ execute: async (_id, params) => {
2672
+ const prov = verifyOperatorProvenance(params.operator_token);
2673
+ if (!prov.ok)
2674
+ return jsonResult({ error: prov.error });
2675
+ return jsonResult(await testExchangeCredentialsTool(params));
2676
+ },
2668
2677
  },
2669
2678
  {
2670
2679
  name: 'clear_exchange_credentials',
@@ -2683,7 +2692,9 @@ const paperTradingPlugin = {
2683
2692
  label: 'Get Bracket Config',
2684
2693
  description: 'Operator-only. Returns the current bracket-orders configuration (mode + requireStopLoss + requireTakeProfit). Used by the dashboard Trading Parameters panel.',
2685
2694
  parameters: TOOL_PARAMS.get_bracket_config,
2686
- execute: async () => jsonResult(getBracketConfigTool({})),
2695
+ // Adapter read at execute time (not registration) so a PAPER→HL-live
2696
+ // swap changes the reported protection without a restart.
2697
+ execute: async () => jsonResult(getBracketConfigTool({}, { adapter: runtime.adapter })),
2687
2698
  },
2688
2699
  {
2689
2700
  name: 'set_bracket_requirement',
@@ -7,5 +7,16 @@ export declare function startEventLoopMonitor(): void;
7
7
  * The histogram stores nanoseconds; `max` is Infinity-safe but can read as a
8
8
  * sentinel before the first sample lands, so anything non-finite → null. */
9
9
  export declare function sampleEventLoopDelayMs(): number | null;
10
+ /** Time (ms) this process spent waiting for a CPU since the previous call, then
11
+ * re-baselines — so consecutive calls partition the timeline into the SAME
12
+ * non-overlapping windows as `sampleEventLoopDelayMs`, and the two readings of
13
+ * one cycle describe one window.
14
+ *
15
+ * ★ Call this EVERY cycle, not only when a stall was observed: the counter is
16
+ * cumulative since process start, so a first read taken at the moment of a
17
+ * freeze would report hours of ordinary scheduling as if it were the freeze.
18
+ *
19
+ * Returns null when unavailable or on the first (baseline-establishing) call. */
20
+ export declare function sampleRunqueueWaitMs(): number | null;
10
21
  /** Test-only — drop the singleton so a fresh histogram is created. */
11
22
  export declare function __resetEventLoopMonitorForTests(): void;
@@ -25,6 +25,7 @@
25
25
  // createRequire dance exists for that dependency's module shape, not for
26
26
  // builtins. See docs/CLAUDE/plugin-integration.md.
27
27
  import { monitorEventLoopDelay } from 'node:perf_hooks';
28
+ import { readFileSync } from 'node:fs';
28
29
  import { logger } from '../logger.js';
29
30
  const TAG = 'event-loop-monitor';
30
31
  /** Sampling resolution. 20ms is fine-grained enough to catch a real stall
@@ -99,6 +100,80 @@ export function sampleEventLoopDelayMs() {
99
100
  return null;
100
101
  }
101
102
  }
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+ // Runqueue wait — the measurement that says WHO froze the loop.
105
+ //
106
+ // ★ Event-loop delay is a symptom with two very different causes: the host
107
+ // descheduled us (co-tenant load, an oversubscribed VPS) or we blocked our own
108
+ // loop (a synchronous burst — on a ReefClaw box, an LLM turn, which runs
109
+ // EMBEDDED in this same process alongside order handling). The histogram above
110
+ // cannot tell them apart, so the check used to assert the first and tell the
111
+ // operator to buy more CPU. Verified wrong on prod 2026-07-29: a "froze 5.9s"
112
+ // warn on a box at 0.05 load, 0 steal, 0 swap, whose cumulative runqueue wait
113
+ // was 1.95s across the whole process lifetime. Resizing would have done nothing.
114
+ //
115
+ // /proc/self/schedstat field 2 is the kernel's own count of nanoseconds this
116
+ // thread spent RUNNABLE but waiting for a CPU. It is exactly the discriminator:
117
+ // large ⇒ the host really is starving us; ~0 while the loop froze ⇒ the freeze
118
+ // came from inside. `/proc/self` is the thread-group leader = the main thread,
119
+ // which is where the event loop runs, so this is the right thread to ask.
120
+ //
121
+ // Same evidence rule as everything else on this path (issue #265): unavailable
122
+ // ⇒ null ⇒ 'unknown' attribution and the cautious copy — never a fabricated
123
+ // zero, which would read as "definitely self-inflicted" and is the same
124
+ // false-confidence bug pointed the other way.
125
+ const SCHEDSTAT_PATH = '/proc/self/schedstat';
126
+ /** Cumulative ns at the previous sample; null until the first read establishes
127
+ * a baseline. A delta needs two points — the first call can only arm. */
128
+ let lastRunqueueWaitNs = null;
129
+ /** Latched after the first failed read: not Linux, or a kernel built without
130
+ * CONFIG_SCHEDSTATS. Stops us re-reading a file that will never exist. */
131
+ let schedstatUnavailable = false;
132
+ /** Read the cumulative runqueue-wait counter (ns). Null when unreadable or
133
+ * malformed — never a guess. */
134
+ function readRunqueueWaitNs() {
135
+ if (schedstatUnavailable)
136
+ return null;
137
+ try {
138
+ // Three space-separated numbers: cpu_time_ns, runqueue_wait_ns, timeslices.
139
+ const parts = readFileSync(SCHEDSTAT_PATH, 'utf8').trim().split(/\s+/);
140
+ const ns = Number(parts[1]);
141
+ if (!Number.isFinite(ns) || ns < 0) {
142
+ schedstatUnavailable = true;
143
+ return null;
144
+ }
145
+ return ns;
146
+ }
147
+ catch {
148
+ schedstatUnavailable = true;
149
+ logger.info(TAG, 'runqueue-wait unavailable (not Linux, or kernel without CONFIG_SCHEDSTATS) — host-stall attribution will report unknown');
150
+ return null;
151
+ }
152
+ }
153
+ /** Time (ms) this process spent waiting for a CPU since the previous call, then
154
+ * re-baselines — so consecutive calls partition the timeline into the SAME
155
+ * non-overlapping windows as `sampleEventLoopDelayMs`, and the two readings of
156
+ * one cycle describe one window.
157
+ *
158
+ * ★ Call this EVERY cycle, not only when a stall was observed: the counter is
159
+ * cumulative since process start, so a first read taken at the moment of a
160
+ * freeze would report hours of ordinary scheduling as if it were the freeze.
161
+ *
162
+ * Returns null when unavailable or on the first (baseline-establishing) call. */
163
+ export function sampleRunqueueWaitMs() {
164
+ const ns = readRunqueueWaitNs();
165
+ if (ns == null)
166
+ return null;
167
+ const prev = lastRunqueueWaitNs;
168
+ lastRunqueueWaitNs = ns;
169
+ if (prev == null)
170
+ return null;
171
+ // Counters only climb; a decrease means we are not reading what we think we
172
+ // are (or it wrapped). Report unknown rather than a negative/garbage window.
173
+ if (ns < prev)
174
+ return null;
175
+ return (ns - prev) / 1e6;
176
+ }
102
177
  /** Test-only — drop the singleton so a fresh histogram is created. */
103
178
  export function __resetEventLoopMonitorForTests() {
104
179
  try {
@@ -110,4 +185,6 @@ export function __resetEventLoopMonitorForTests() {
110
185
  histogram = null;
111
186
  initFailed = false;
112
187
  armed = false;
188
+ lastRunqueueWaitNs = null;
189
+ schedstatUnavailable = false;
113
190
  }
@@ -1,4 +1,11 @@
1
1
  import { type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
2
+ /** Who froze the loop. 'unknown' when the kernel counter is unreadable (not
3
+ * Linux / no CONFIG_SCHEDSTATS / first cycle) — attribution is evidence, and
4
+ * absent evidence stays absent rather than defaulting to a blame. */
5
+ export type StallAttribution = 'host' | 'self' | 'unknown';
6
+ /** Compare the freeze against the CPU the host actually denied us. Exported for
7
+ * unit tests — this is the whole discrimination rule in one place. */
8
+ export declare function attributeStall(stallMs: number, runqueueWaitMs: number | null): StallAttribution;
2
9
  /** Venue-agnostic reachability probe — BinancePublicApi.probeReachability and
3
10
  * HyperliquidPublicApi.probeReachability both return exactly this shape. */
4
11
  export interface VenueReachabilityProbe {
@@ -46,6 +53,8 @@ export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'v
46
53
  state?: ReadinessCycleState;
47
54
  /** Injected for tests; production reads the real event-loop histogram. */
48
55
  sampleHostStallMs?: () => number | null;
56
+ /** Injected for tests; production reads /proc/self/schedstat. */
57
+ sampleRunqueueWaitMs?: () => number | null;
49
58
  }): Promise<ReadinessReport>;
50
59
  /** Test-only — reset the singleton guard between unit tests. */
51
60
  export declare function __resetReadinessReporterForTests(): void;
@@ -8,9 +8,9 @@
8
8
  // module-level singleton guard mirrors `pluginInitialised` in index.ts — OpenClaw
9
9
  // calls register() multiple times per process and we must never spawn a second
10
10
  // timer. The interval is unref()'d so it never holds the process open.
11
- import { makeReadinessCheck, deriveOverallReadiness, GEO_BLOCK_FIX_HINT, } from '@reefclaw/shared';
11
+ import { makeReadinessCheck, deriveOverallReadiness, GEO_BLOCK_FIX_HINT, HOST_STALL_FIX_HINT, } from '@reefclaw/shared';
12
12
  import { logger, formatError } from '../logger.js';
13
- import { startEventLoopMonitor, sampleEventLoopDelayMs } from './event-loop-monitor.js';
13
+ import { startEventLoopMonitor, sampleEventLoopDelayMs, sampleRunqueueWaitMs, } from './event-loop-monitor.js';
14
14
  const TAG = 'readiness';
15
15
  const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
16
16
  const MIN_INTERVAL_MS = 60_000;
@@ -25,6 +25,24 @@ const REACH_WARN_AFTER_FAILURES = 2;
25
25
  * Well above ordinary GC/boot jitter (tens to low hundreds of ms) and far
26
26
  * below the freezes worth alarming on — the live incident produced 89s-556s. */
27
27
  const HOST_STALL_WARN_MS = 5_000;
28
+ /** How much of a freeze the kernel must attribute to runqueue wait before we
29
+ * blame the HOST for it. Half is deliberately generous to the host-starved
30
+ * reading: a host that froze us for N seconds necessarily made us wait most of
31
+ * those N seconds, while a self-blocked loop accrues ~nothing (prod: 1.95s
32
+ * across an entire process lifetime, against a 5.9s freeze in one cycle). */
33
+ const HOST_WAIT_SHARE_OF_STALL = 0.5;
34
+ /** Absolute floor under the share test. Sub-second waiting is ordinary
35
+ * scheduling on any box and must never read as starvation, however short the
36
+ * freeze it is compared against. */
37
+ const HOST_WAIT_FLOOR_MS = 1_000;
38
+ /** Compare the freeze against the CPU the host actually denied us. Exported for
39
+ * unit tests — this is the whole discrimination rule in one place. */
40
+ export function attributeStall(stallMs, runqueueWaitMs) {
41
+ if (runqueueWaitMs == null)
42
+ return 'unknown';
43
+ const hostThreshold = Math.max(HOST_WAIT_FLOOR_MS, stallMs * HOST_WAIT_SHARE_OF_STALL);
44
+ return runqueueWaitMs >= hostThreshold ? 'host' : 'self';
45
+ }
28
46
  export function createReadinessCycleState() {
29
47
  return { consecutiveReachFailures: 0 };
30
48
  }
@@ -52,6 +70,7 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
52
70
  const checks = [];
53
71
  const state = deps.state ?? createReadinessCycleState();
54
72
  const sampleStall = deps.sampleHostStallMs ?? sampleEventLoopDelayMs;
73
+ const sampleWait = deps.sampleRunqueueWaitMs ?? sampleRunqueueWaitMs;
55
74
  // The venue decides which reachability check this report carries; the copy
56
75
  // for both ids lives in shared/src/readiness.ts. Clock drift comes from the
57
76
  // same probe on both venues (Binance fapi serverTime / Hyperliquid
@@ -77,6 +96,12 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
77
96
  // 'unknown', never a fabricated healthy zero — except that a probe which
78
97
  // measured its own overshoot IS a stall measurement, so it stands in.
79
98
  const stallMs = sampleStall() ?? (probe.outcome === 'stalled' ? (probe.stallMs ?? null) : null);
99
+ // ★ Sampled unconditionally, even on a passing cycle: the counter behind it is
100
+ // cumulative, so it must be re-baselined every cycle for its window to line up
101
+ // with the freeze window. Reading it only when a stall appeared would charge
102
+ // that one freeze with every millisecond of ordinary scheduling since boot and
103
+ // score a self-blocked loop as a starved host.
104
+ const runqueueWaitMs = sampleWait();
80
105
  if (stallMs == null) {
81
106
  checks.push(makeReadinessCheck('host_responsive', 'unknown', { checkedAt: now }));
82
107
  }
@@ -85,16 +110,40 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
85
110
  // Boot congestion (WS start, snapshot, seed all racing) legitimately stalls
86
111
  // the loop for seconds — same warm-up rule as the clock check below.
87
112
  const status = bootWarmup && rawStatus !== 'pass' ? 'unknown' : rawStatus;
113
+ // WHO froze it — computed only for a real freeze, since the whole question
114
+ // is what to tell the operator to go fix.
115
+ const attribution = rawStatus === 'pass' ? 'unknown' : attributeStall(stallMs, runqueueWaitMs);
116
+ const frozeSecs = (stallMs / 1000).toFixed(1);
117
+ const waitedSecs = runqueueWaitMs != null ? (runqueueWaitMs / 1000).toFixed(1) : null;
118
+ // The detail line carries the evidence, not just the verdict: an operator
119
+ // who disagrees with the attribution can see the number it rests on.
120
+ const stallDetail = attribution === 'host'
121
+ ? `froze ${frozeSecs}s (waited ${waitedSecs}s for CPU)`
122
+ : attribution === 'self'
123
+ ? `froze ${frozeSecs}s (host had CPU free — blocked inside the agent process)`
124
+ : `froze ${frozeSecs}s`;
88
125
  checks.push(makeReadinessCheck('host_responsive', status, {
89
126
  detail: rawStatus === 'pass'
90
127
  ? `max loop delay ${Math.round(stallMs)}ms`
91
128
  : bootWarmup
92
- ? `froze ${(stallMs / 1000).toFixed(1)}s (boot warm-up — rechecking)`
93
- : `froze ${(stallMs / 1000).toFixed(1)}s`,
129
+ ? `froze ${frozeSecs}s (boot warm-up — rechecking)`
130
+ : stallDetail,
131
+ // Only an attributed stall may name its cause; 'unknown' falls through to
132
+ // the cautious default in the copy map (issue #265 rule).
133
+ ...(attribution === 'host'
134
+ ? { fixHint: HOST_STALL_FIX_HINT.host_starved }
135
+ : attribution === 'self'
136
+ ? { fixHint: HOST_STALL_FIX_HINT.self_inflicted }
137
+ : {}),
94
138
  checkedAt: now,
95
139
  }));
96
140
  if (rawStatus !== 'pass' && !bootWarmup) {
97
- logger.warn(TAG, `host starved: event loop froze ${(stallMs / 1000).toFixed(1)}s this cycle — order handling and bracket resync can lag by that much`);
141
+ const cause = attribution === 'host'
142
+ ? `host starved: waited ${waitedSecs}s for CPU`
143
+ : attribution === 'self'
144
+ ? `blocked inside this process (host had CPU free — waited only ${waitedSecs}s)`
145
+ : 'cause unattributable (runqueue wait unreadable)';
146
+ logger.warn(TAG, `event loop froze ${frozeSecs}s this cycle — order handling and bracket resync can lag by that much; ${cause}`);
98
147
  }
99
148
  }
100
149
  // venue reachability (+ clock drift from the same probe response)
@@ -297,8 +297,16 @@ export class UserDataStream extends EventEmitter {
297
297
  // Capped to avoid flooding the journal under heavy bracket
298
298
  // traffic. Operators read these first-N samples once after
299
299
  // deploy to verify the parser matches Binance's actual shape.
300
- logger.info(TAG, `ALGO_UPDATE raw (${this.algoUpdateRawLogged + 1}/` +
301
- `${UserDataStream.ALGO_UPDATE_RAW_LOG_CAP}): ${JSON.stringify(obj)}`);
300
+ // Raw account payloads (order ids, prices, quantities) only
301
+ // reach the journal behind an explicit opt-in — by default we
302
+ // log the parser-relevant SHAPE (field names), which is what
303
+ // shape-drift verification actually needs.
304
+ const raw = process.env.RC_LOG_RAW_WS === 'on';
305
+ const shape = raw
306
+ ? JSON.stringify(obj)
307
+ : `keys=[${Object.keys(obj).join(',')}] event.keys=[${Object.keys(obj.o ?? {}).join(',')}]`;
308
+ logger.info(TAG, `ALGO_UPDATE ${raw ? 'raw' : 'shape'} (${this.algoUpdateRawLogged + 1}/` +
309
+ `${UserDataStream.ALGO_UPDATE_RAW_LOG_CAP}): ${shape}`);
302
310
  this.algoUpdateRawLogged++;
303
311
  }
304
312
  this.emit('algoUpdate', ev);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.14",
4
+ "version": "0.1.16",
5
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": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.14",
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",
3
+ "version": "0.1.16",
4
+ "description": "ReefClaw supervised trading plugin for OpenClaw 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": {
@@ -16,19 +16,22 @@
16
16
  }
17
17
  },
18
18
  "files": [
19
- "**/*"
19
+ "**/*",
20
+ "!scripts/**"
20
21
  ],
21
22
  "engines": {
22
23
  "node": ">=20"
23
24
  },
24
25
  "dependencies": {
25
- "@reefclaw/shared": "0.1.3",
26
+ "@reefclaw/shared": "0.1.4",
26
27
  "ccxt": "4.5.37",
27
28
  "json5": "2.2.3",
28
- "ws": "8.19.0"
29
+ "ws": "8.21.1"
29
30
  },
30
31
  "scripts": {
31
- "build": "node scripts/assemble.mjs"
32
+ "build": "node scripts/assemble.mjs",
33
+ "verify": "node scripts/verify-shared-contract.mjs",
34
+ "prepublishOnly": "node scripts/verify-shared-contract.mjs"
32
35
  },
33
36
  "license": "MIT",
34
37
  "homepage": "https://reefclaw.com"